aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-07-07 05:50:37 -0400
committerhistoria <[not public]>2026-07-07 05:50:37 -0400
commit3f30fa3eec9c2e2acf9a984ccefb9529a25e688a (patch)
treeb6c146ec7f3dc2ea21dbf8ee7612c3889d93ca1a /internal/game
parenta51f7a53aa2c487ebf94ba0363038574ae8bb590 (diff)
downloadthehouseoficarus-3f30fa3eec9c2e2acf9a984ccefb9529a25e688a.tar.gz
feat: add insert/remove room to context menu in admin web GUI to 'push' or 'pull' map rooms
Diffstat (limited to 'internal/game')
-rw-r--r--internal/game/cmd_room.go3
-rw-r--r--internal/game/cmd_room_insert.go91
-rw-r--r--internal/game/cmd_room_remove.go292
-rw-r--r--internal/game/cmd_room_remove_test.go331
4 files changed, 649 insertions, 68 deletions
diff --git a/internal/game/cmd_room.go b/internal/game/cmd_room.go
index 971e298..3210ef5 100644
--- a/internal/game/cmd_room.go
+++ b/internal/game/cmd_room.go
@@ -51,6 +51,8 @@ func (g *Game) executeRoom(sess *net.Session, args []string, rawInput string) {
g.roomSetColor(sess, rest)
case "insert":
g.roomInsert(sess, rest)
+ case "remove":
+ g.roomRemove(sess, rest)
default:
sess.WriteLine(fmt.Sprintf("Unknown room action: %s", action))
g.showRoomHelp(sess)
@@ -73,6 +75,7 @@ func (g *Game) showRoomHelp(sess *net.Session) {
sess.WriteLine(" room remspawn <item_id> — remove an item spawn")
sess.WriteLine(" room color <spec|off> — set or clear room map color")
sess.WriteLine(" room insert <direction> [name] — insert a new room into the exit, pushing rooms beyond")
+ sess.WriteLine(" room remove <direction> — delete the inserted room and pull the far end back")
}
func (g *Game) roomSetName(sess *net.Session, args []string) {
diff --git a/internal/game/cmd_room_insert.go b/internal/game/cmd_room_insert.go
index 988cd08..b72591f 100644
--- a/internal/game/cmd_room_insert.go
+++ b/internal/game/cmd_room_insert.go
@@ -65,53 +65,42 @@ func (g *Game) roomInsert(sess *net.Session, args []string) {
oppositeDir := world.OppositeExit[dir]
- if delta3D, ok := world.DirectionDeltas3D[dir]; ok {
- coord, roomAt := g.buildGridFrom(p.RoomID)
-
- sSet := g.bfsReachable(targetID, func(_ int, _ world.ExitDir, target int) bool {
- return target == p.RoomID
- })
+ newID, err := findNextRoomID(curPath)
+ if err != nil {
+ sess.WriteLine("Error scanning room directory.")
+ return
+ }
- withoutEdge := g.bfsReachable(p.RoomID, func(rid int, d world.ExitDir, target int) bool {
- return rid == p.RoomID && d == dir && target == targetID
- })
- for rid := range sSet {
- if withoutEdge[rid] {
- room, _ := g.World.LoadRoom(rid)
- conflictName := fmt.Sprintf("#%d", rid)
+ conflicts := world.InsertGridConflicts(p.RoomID, dir, targetID, newID, func(id int) (*world.Room, bool) {
+ r, err := g.World.LoadRoom(id)
+ if err != nil {
+ return nil, false
+ }
+ return r, true
+ })
+ if len(conflicts) > 0 {
+ for _, c := range conflicts {
+ switch c.Kind {
+ case "twist":
+ room, _ := g.World.LoadRoom(c.Target)
+ conflictName := fmt.Sprintf("#%d", c.Target)
if room != nil {
- conflictName = fmt.Sprintf("#%d (%s)", rid, room.Name)
+ conflictName = fmt.Sprintf("#%d (%s)", c.Target, room.Name)
}
sess.WriteLine(fmt.Sprintf(
"Cannot insert: room %s would have an ambiguous grid position after insertion (reachable via an alternate path from here).",
conflictName))
- return
- }
- }
-
- for rid := range sSet {
- if _, inGrid := coord[rid]; !inGrid {
- continue
- }
- oldPos := coord[rid]
- newPos := [3]int{oldPos[0] + delta3D[0], oldPos[1] + delta3D[1], oldPos[2] + delta3D[2]}
- if occupier, ok := roomAt[newPos]; ok && !sSet[occupier] {
- occRoom, _ := g.World.LoadRoom(occupier)
- occName := fmt.Sprintf("#%d", occupier)
+ case "overlap":
+ occRoom, _ := g.World.LoadRoom(c.Occupier)
+ occName := fmt.Sprintf("#%d", c.Occupier)
if occRoom != nil {
- occName = fmt.Sprintf("#%d (%s)", occupier, occRoom.Name)
+ occName = fmt.Sprintf("#%d (%s)", c.Occupier, occRoom.Name)
}
sess.WriteLine(fmt.Sprintf(
"Cannot insert: pushing %s would cause grid collision with %s.",
targetName, occName))
- return
}
}
- }
-
- newID, err := findNextRoomID(curPath)
- if err != nil {
- sess.WriteLine("Error scanning room directory.")
return
}
@@ -228,37 +217,3 @@ func (g *Game) roomInsert(sess *net.Session, args []string) {
g.runEnterSteps(sess, newID)
g.checkAggro(sess)
}
-
-// bfsReachable returns the set of rooms reachable from startID over the room
-// exit graph, restricted to known rooms. skipEdge, when non-nil, prunes an
-// individual directed exit (the edge from rid via dir to target) from the walk.
-func (g *Game) bfsReachable(startID int, skipEdge func(rid int, dir world.ExitDir, target int) bool) map[int]bool {
- roomIndex := g.World.RoomIndex()
- visited := map[int]bool{startID: true}
- queue := []int{startID}
-
- for len(queue) > 0 {
- rid := queue[0]
- queue = queue[1:]
- room, err := g.World.LoadRoom(rid)
- if err != nil {
- continue
- }
- for _, ed := range world.ExitOrder {
- exit, ok := room.Exits[ed]
- if !ok || exit.Room <= 0 || !roomIndex[exit.Room] {
- continue
- }
- target := exit.Room
- if skipEdge != nil && skipEdge(rid, ed, target) {
- continue
- }
- if visited[target] {
- continue
- }
- visited[target] = true
- queue = append(queue, target)
- }
- }
- return visited
-}
diff --git a/internal/game/cmd_room_remove.go b/internal/game/cmd_room_remove.go
new file mode 100644
index 0000000..78c1234
--- /dev/null
+++ b/internal/game/cmd_room_remove.go
@@ -0,0 +1,292 @@
+package game
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "gopkg.in/yaml.v3"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/world"
+)
+
+// roomRemove is the inverse of roomInsert: it deletes the room reached via the
+// current room's dir exit (the "inserted" room B) and pulls the far room C (B's
+// dir exit's target) back to the current room, rewiring A→dir→C and
+// C→oppositeDir→A. The beyond-rooms shift one step toward A. It fails when:
+// - B does not lead back to the current room via oppositeDir (not an insert
+// chain),
+// - B has no forward dir exit (a dead end — use `undig` for that),
+// - B has any exit other than oppositeDir and dir (other exits in the deleted
+// room),
+// - C's opposite exit does not point back at B (broken reciprocity),
+// - some other room points into B (extra inbound edge — B was modified since
+// insertion),
+// - the pull would cause a grid overlap or twist (RemoveGridConflicts).
+//
+// Admin gating is inherited from executeRoom.
+func (g *Game) roomRemove(sess *net.Session, args []string) {
+ p := sess.Player
+ if p == nil {
+ return
+ }
+
+ if len(args) == 0 {
+ sess.WriteLine("Usage: room remove <direction>")
+ return
+ }
+
+ dir := g.World.ResolveExit(args[0])
+ if dir == "" {
+ sess.WriteLine("Invalid direction. Use n/s/e/w/ne/nw/se/sw/u/d.")
+ return
+ }
+
+ oppositeDir := world.OppositeExit[dir]
+ aID := p.RoomID
+
+ aPath, ok := g.World.GetRoomPath(aID)
+ if !ok {
+ sess.WriteLine("Error: can't find current room file.")
+ return
+ }
+ aRoom, err := g.World.LoadRoom(aID)
+ if err != nil {
+ sess.WriteLine("Error loading current room.")
+ return
+ }
+
+ aExit, hasExit := aRoom.Exits[dir]
+ if !hasExit {
+ sess.WriteLine(fmt.Sprintf("There is no exit to the %s from here.", dir))
+ return
+ }
+ bID := aExit.Room
+ if bID == aID {
+ sess.WriteLine("That exit loops back to this room; nothing to remove.")
+ return
+ }
+
+ bRoom, err := g.World.LoadRoom(bID)
+ if err != nil {
+ sess.WriteLine(fmt.Sprintf("Target room %d not found.", bID))
+ return
+ }
+ bName := fmt.Sprintf("#%d (%s)", bID, bRoom.Name)
+
+ // Guard 1: B must lead back to A via oppositeDir.
+ backExit, hasBack := bRoom.Exits[oppositeDir]
+ if !hasBack || backExit.Room != aID {
+ sess.WriteLine(fmt.Sprintf(
+ "Cannot remove: %s does not lead back here via %s (not an insert chain).",
+ bName, oppositeDir))
+ return
+ }
+
+ // Guard 2: B must have a forward dir exit (a far room C to pull).
+ fwdExit, hasFwd := bRoom.Exits[dir]
+ if !hasFwd {
+ sess.WriteLine(fmt.Sprintf(
+ "Cannot remove: %s has no room beyond to pull (use `undig %s` to delete a dead-end room).",
+ bName, dir))
+ return
+ }
+ cID := fwdExit.Room
+ if cID == bID || cID == aID {
+ sess.WriteLine(fmt.Sprintf("Cannot remove: the far room in %s is not a distinct room.", dir))
+ return
+ }
+
+ // Guard 3: B must have exactly two exits (oppositeDir back to A and dir
+ // forward to C). Any other exit means B is not a clean insert chain.
+ if len(bRoom.Exits) != 2 {
+ sess.WriteLine(fmt.Sprintf(
+ "Cannot remove: %s has other exits besides %s and %s.",
+ bName, oppositeDir, dir))
+ return
+ }
+
+ // Guard 4: C's opposite exit must point back at B.
+ cRoom, err := g.World.LoadRoom(cID)
+ if err != nil {
+ sess.WriteLine(fmt.Sprintf("Far room %d not found.", cID))
+ return
+ }
+ cOppExit, hasCOpp := cRoom.Exits[oppositeDir]
+ if !hasCOpp || cOppExit.Room != bID {
+ sess.WriteLine(fmt.Sprintf(
+ "Cannot remove: the far room #%d does not lead back to %s via %s.",
+ cID, bName, oppositeDir))
+ return
+ }
+
+ // Guard 5: no other room points into B. The only allowed inbound edges are
+ // A→dir→B and C→oppositeDir→B.
+ if extra := g.countExtraInbound(bID, aID, cID, dir, oppositeDir); extra > 0 {
+ sess.WriteLine(fmt.Sprintf(
+ "Cannot remove: %d other exit(s) point into %s (it was modified since insertion).",
+ extra, bName))
+ return
+ }
+
+ // Guard 6: the pull must not cause a grid overlap or twist.
+ conflicts := world.RemoveGridConflicts(aID, dir, func(id int) (*world.Room, bool) {
+ r, err := g.World.LoadRoom(id)
+ if err != nil {
+ return nil, false
+ }
+ return r, true
+ })
+ if len(conflicts) > 0 {
+ for _, c := range conflicts {
+ switch c.Kind {
+ case "twist":
+ room, _ := g.World.LoadRoom(c.Target)
+ name := fmt.Sprintf("#%d", c.Target)
+ if room != nil {
+ name = fmt.Sprintf("#%d (%s)", c.Target, room.Name)
+ }
+ sess.WriteLine(fmt.Sprintf(
+ "Cannot remove: pulling the far end would give room %s an ambiguous grid position.",
+ name))
+ case "overlap":
+ occRoom, _ := g.World.LoadRoom(c.Occupier)
+ occName := fmt.Sprintf("#%d", c.Occupier)
+ if occRoom != nil {
+ occName = fmt.Sprintf("#%d (%s)", c.Occupier, occRoom.Name)
+ }
+ sess.WriteLine(fmt.Sprintf(
+ "Cannot remove: pulling the far end would cause grid collision with %s.",
+ occName))
+ }
+ }
+ return
+ }
+
+ // Apply: rewire A's dir exit to C, preserving A's current dir-exit props.
+ aRoom.Exits[dir] = world.ExitDef{
+ Room: cID,
+ Condition: aExit.Condition,
+ BlockedMessage: aExit.BlockedMessage,
+ SetFlags: aExit.SetFlags,
+ SetPlayerFlags: aExit.SetPlayerFlags,
+ Hidden: aExit.Hidden,
+ AlwaysBlocked: aExit.AlwaysBlocked,
+ }
+ aData, err := yaml.Marshal(aRoom)
+ if err != nil {
+ sess.WriteLine("Error updating current room YAML.")
+ return
+ }
+ if err := os.WriteFile(aPath, aData, 0644); err != nil {
+ sess.WriteLine("Error writing current room file.")
+ return
+ }
+
+ // Rewire C's opposite exit to A, preserving C's current opposite-exit props.
+ cRoom.Exits[oppositeDir] = world.ExitDef{
+ Room: aID,
+ Condition: cOppExit.Condition,
+ BlockedMessage: cOppExit.BlockedMessage,
+ SetFlags: cOppExit.SetFlags,
+ SetPlayerFlags: cOppExit.SetPlayerFlags,
+ Hidden: cOppExit.Hidden,
+ AlwaysBlocked: cOppExit.AlwaysBlocked,
+ }
+ cPath, cPathOK := g.World.GetRoomPath(cID)
+ if cPathOK {
+ cData, cerr := yaml.Marshal(cRoom)
+ if cerr == nil {
+ os.WriteFile(cPath, cData, 0644)
+ }
+ }
+
+ // Delete B's file.
+ bPath, bPathOK := g.World.GetRoomPath(bID)
+ if !bPathOK {
+ sess.WriteLine(fmt.Sprintf("Room file for %d not found.", bID))
+ return
+ }
+ if rerr := os.Remove(bPath); rerr != nil {
+ sess.WriteLine(fmt.Sprintf("Error deleting room file: %v", rerr))
+ return
+ }
+
+ g.World.RebuildRoomIndex(g.DataDir)
+ g.MobStore.RemoveMobsInRoom(bID)
+ g.World.ClearRoomState(bID)
+
+ // Relocate any other players standing in B to A (the current player is in
+ // A and unaffected). Mirrors performUndig's relocate tail.
+ for _, other := range g.Hub.AllSessions() {
+ if other == sess || other.Player == nil {
+ continue
+ }
+ if other.Player.RoomID == bID {
+ other.Player.RoomID = aID
+ if g.Hub != nil {
+ g.Hub.EnterRoom(other, aID)
+ }
+ other.WriteLine("The room around you collapses. You find yourself elsewhere.")
+ g.doLook(other)
+ }
+ }
+
+ cName := fmt.Sprintf("#%d (%s)", cID, cRoom.Name)
+ sess.WriteLine(fmt.Sprintf("You remove %s and pull %s back to the %s.", bName, cName, dir))
+ g.doLook(sess)
+}
+
+// countExtraInbound returns the number of exits in rooms OTHER than A and C
+// that point into B, plus any A/C exits to B other than the expected
+// (A→dir→B, C→oppositeDir→B). A nonzero result means B has been modified since
+// it was inserted and the remove would silently orphan edges. It walks
+// data/rooms/ and matches \b<id>\b against raw file contents to skip rooms
+// that don't mention bID at all, then unmarshals the matching files.
+func (g *Game) countExtraInbound(bID, aID, cID int, dir, oppositeDir world.ExitDir) int {
+ roomsDir := filepath.Join(g.DataDir, "rooms")
+ re := regexp.MustCompile(fmt.Sprintf(`\b%d\b`, bID))
+ var extra int
+ _ = filepath.WalkDir(roomsDir, func(path string, d os.DirEntry, err error) error {
+ if err != nil || d.IsDir() || filepath.Ext(path) != ".yaml" {
+ return nil
+ }
+ data, rerr := os.ReadFile(path)
+ if rerr != nil {
+ return nil
+ }
+ if !re.MatchString(string(data)) {
+ return nil
+ }
+ idStr := strings.TrimSuffix(filepath.Base(path), ".yaml")
+ rid, serr := strconv.Atoi(idStr)
+ if serr != nil || rid == bID {
+ return nil
+ }
+ var room world.Room
+ if yerr := yaml.Unmarshal(data, &room); yerr != nil {
+ return nil
+ }
+ if room.Exits == nil {
+ return nil
+ }
+ for ed, exitDef := range room.Exits {
+ if exitDef.Room != bID {
+ continue
+ }
+ // Allowed: A→dir→B and C→oppositeDir→B.
+ if rid == aID && ed == dir {
+ continue
+ }
+ if rid == cID && ed == oppositeDir {
+ continue
+ }
+ extra++
+ }
+ return nil
+ })
+ return extra
+}
diff --git a/internal/game/cmd_room_remove_test.go b/internal/game/cmd_room_remove_test.go
new file mode 100644
index 0000000..1868c30
--- /dev/null
+++ b/internal/game/cmd_room_remove_test.go
@@ -0,0 +1,331 @@
+package game
+
+import (
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/combat"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+ "thehouseoficarus/internal/world"
+)
+
+// removeTestConn is a minimal Conn that records writes for assertions.
+type removeTestConn struct{ buf []byte }
+
+func (c *removeTestConn) ReadMessage() (string, error) { return "", nil }
+func (c *removeTestConn) Write(b []byte) (int, error) { c.buf = append(c.buf, b...); return len(b), nil }
+func (c *removeTestConn) Close() error { return nil }
+func (c *removeTestConn) SetEcho(bool) error { return nil }
+
+func (c *removeTestConn) output() string { return string(c.buf) }
+
+// newRemoveGame builds a Game with the data stores roomRemove touches, a Hub
+// (so relocate logic is exercised), and no telephony. Rooms are written into
+// dir by the caller.
+func newRemoveGame(t *testing.T, dir string) *Game {
+ t.Helper()
+ g := &Game{
+ Deps: Deps{
+ World: world.New(dir),
+ MobStore: world.NewMobStore(dir),
+ AccountStore: player.NewAccountStore(dir),
+ DataDir: dir,
+ },
+ Hub: net.NewHub(),
+ Combat: combat.NewTracker(),
+ safespot: NewSafespotManager(),
+ }
+ return g
+}
+
+func newRemoveSession(p *player.Player) *net.Session {
+ return &net.Session{Conn: &removeTestConn{}, Player: p, State: net.StateGame}
+}
+
+func writeRemoveRoomFile(t *testing.T, dir string, id int, body string) {
+ t.Helper()
+ roomsDir := filepath.Join(dir, "rooms")
+ if err := os.MkdirAll(roomsDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(roomsDir, strconv.Itoa(id)+".yaml"), []byte(body), 0644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func assertOutContains(t *testing.T, sess *net.Session, sub string) {
+ t.Helper()
+ out := sess.Conn.(*removeTestConn).output()
+ if !strings.Contains(out, sub) {
+ t.Errorf("expected session output to contain %q, got:\n%s", sub, out)
+ }
+}
+
+func assertOutNotContains(t *testing.T, sess *net.Session, sub string) {
+ t.Helper()
+ out := sess.Conn.(*removeTestConn).output()
+ if strings.Contains(out, sub) {
+ t.Errorf("expected session output to NOT contain %q, got:\n%s", sub, out)
+ }
+}
+
+// reloadAfterCmd re-reads a room from disk after a command has mutated it.
+func reloadRoom(t *testing.T, g *Game, id int) *world.Room {
+ t.Helper()
+ r, err := g.World.LoadRoom(id)
+ if err != nil {
+ t.Fatalf("reload room %d: %v", id, err)
+ }
+ return r
+}
+
+func TestRoomRemoveSuccessPull(t *testing.T) {
+ dir := t.TempDir()
+ writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n east: 2\n")
+ writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n east: 3\n west: 1\n")
+ writeRemoveRoomFile(t, dir, 3, "name: C\nexits:\n west: 2\n")
+ g := newRemoveGame(t, dir)
+ sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1})
+
+ g.roomRemove(sess, []string{"east"})
+
+ assertOutContains(t, sess, "You remove #2 (B) and pull #3 (C) back to the east.")
+
+ // A's east exit now leads to C (3), not B (2).
+ a := reloadRoom(t, g, 1)
+ if a.Exits[world.East].Room != 3 {
+ t.Errorf("A.Exits[east] = %d, want 3", a.Exits[world.East].Room)
+ }
+ // C's west exit now leads to A (1), not B (2).
+ c := reloadRoom(t, g, 3)
+ if c.Exits[world.West].Room != 1 {
+ t.Errorf("C.Exits[west] = %d, want 1", c.Exits[world.West].Room)
+ }
+ // B's file is gone.
+ if _, err := os.Stat(filepath.Join(dir, "rooms", "2.yaml")); !os.IsNotExist(err) {
+ t.Errorf("expected 2.yaml to be deleted, got err=%v", err)
+ }
+}
+
+func TestRoomRemoveDiagonalPull(t *testing.T) {
+ dir := t.TempDir()
+ writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n northeast: 2\n")
+ writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n northeast: 3\n southwest: 1\n")
+ writeRemoveRoomFile(t, dir, 3, "name: C\nexits:\n southwest: 2\n")
+ g := newRemoveGame(t, dir)
+ sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1})
+
+ g.roomRemove(sess, []string{"northeast"})
+
+ assertOutContains(t, sess, "You remove #2 (B) and pull #3 (C) back to the northeast.")
+ a := reloadRoom(t, g, 1)
+ if a.Exits[world.Northeast].Room != 3 {
+ t.Errorf("A.Exits[ne] = %d, want 3", a.Exits[world.Northeast].Room)
+ }
+ c := reloadRoom(t, g, 3)
+ if c.Exits[world.Southwest].Room != 1 {
+ t.Errorf("C.Exits[sw] = %d, want 1", c.Exits[world.Southwest].Room)
+ }
+}
+
+func TestRoomRemoveNoExit(t *testing.T) {
+ dir := t.TempDir()
+ writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n")
+ g := newRemoveGame(t, dir)
+ sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1})
+
+ g.roomRemove(sess, []string{"east"})
+ assertOutContains(t, sess, "There is no exit to the east from here.")
+}
+
+func TestRoomRemoveNotInsertChain(t *testing.T) {
+ dir := t.TempDir()
+ // B does not lead back to A via west.
+ writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n east: 2\n")
+ writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n east: 3\n")
+ writeRemoveRoomFile(t, dir, 3, "name: C\nexits:\n west: 2\n")
+ g := newRemoveGame(t, dir)
+ sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1})
+
+ g.roomRemove(sess, []string{"east"})
+ assertOutContains(t, sess, "does not lead back here via west")
+ // Nothing was deleted.
+ if _, err := os.Stat(filepath.Join(dir, "rooms", "2.yaml")); err != nil {
+ t.Errorf("2.yaml should still exist, got err=%v", err)
+ }
+}
+
+func TestRoomRemoveDeadEndFarRoom(t *testing.T) {
+ dir := t.TempDir()
+ // B leads back to A but has no forward east exit.
+ writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n east: 2\n")
+ writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n west: 1\n")
+ g := newRemoveGame(t, dir)
+ sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1})
+
+ g.roomRemove(sess, []string{"east"})
+ assertOutContains(t, sess, "no room beyond to pull")
+ if _, err := os.Stat(filepath.Join(dir, "rooms", "2.yaml")); err != nil {
+ t.Errorf("2.yaml should still exist, got err=%v", err)
+ }
+}
+
+func TestRoomRemoveOtherExitsInB(t *testing.T) {
+ dir := t.TempDir()
+ // B has east (fwd), west (back), AND up (extra).
+ writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n east: 2\n")
+ writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n east: 3\n west: 1\n up: 4\n")
+ writeRemoveRoomFile(t, dir, 3, "name: C\nexits:\n west: 2\n")
+ writeRemoveRoomFile(t, dir, 4, "name: D\nexits:\n down: 2\n")
+ g := newRemoveGame(t, dir)
+ sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1})
+
+ g.roomRemove(sess, []string{"east"})
+ assertOutContains(t, sess, "has other exits besides west and east")
+ if _, err := os.Stat(filepath.Join(dir, "rooms", "2.yaml")); err != nil {
+ t.Errorf("2.yaml should still exist, got err=%v", err)
+ }
+}
+
+func TestRoomRemoveFarReciprocityBroken(t *testing.T) {
+ dir := t.TempDir()
+ // C's west does not point back to B.
+ writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n east: 2\n")
+ writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n east: 3\n west: 1\n")
+ writeRemoveRoomFile(t, dir, 3, "name: C\nexits:\n")
+ g := newRemoveGame(t, dir)
+ sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1})
+
+ g.roomRemove(sess, []string{"east"})
+ assertOutContains(t, sess, "does not lead back to #2")
+ if _, err := os.Stat(filepath.Join(dir, "rooms", "2.yaml")); err != nil {
+ t.Errorf("2.yaml should still exist, got err=%v", err)
+ }
+}
+
+func TestRoomRemoveExtraInboundEdge(t *testing.T) {
+ dir := t.TempDir()
+ // A clean chain A-east->B-east->C, but an unrelated room 5 also points to B.
+ writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n east: 2\n")
+ writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n east: 3\n west: 1\n")
+ writeRemoveRoomFile(t, dir, 3, "name: C\nexits:\n west: 2\n")
+ writeRemoveRoomFile(t, dir, 5, "name: E\nexits:\n north: 2\n")
+ g := newRemoveGame(t, dir)
+ sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1})
+
+ g.roomRemove(sess, []string{"east"})
+ assertOutContains(t, sess, "other exit(s) point into #2")
+ if _, err := os.Stat(filepath.Join(dir, "rooms", "2.yaml")); err != nil {
+ t.Errorf("2.yaml should still exist, got err=%v", err)
+ }
+}
+
+// TestRoomRemoveOverlap mirrors TestRemoveGridConflictsOverlap: removing B
+// pulls room 6 onto room 5's cell, which must be rejected.
+func TestRoomRemoveOverlap(t *testing.T) {
+ dir := t.TempDir()
+ writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n east: 2\n south: 4\n")
+ writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n east: 3\n west: 1\n")
+ writeRemoveRoomFile(t, dir, 3, "name: C\nexits:\n south: 6\n west: 2\n")
+ writeRemoveRoomFile(t, dir, 4, "name: D\nexits:\n east: 5\n north: 1\n")
+ writeRemoveRoomFile(t, dir, 5, "name: E\nexits:\n west: 4\n")
+ writeRemoveRoomFile(t, dir, 6, "name: F\nexits:\n north: 3\n")
+ g := newRemoveGame(t, dir)
+ sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1})
+
+ g.roomRemove(sess, []string{"east"})
+ assertOutContains(t, sess, "pulling the far end would cause grid collision")
+ if _, err := os.Stat(filepath.Join(dir, "rooms", "2.yaml")); err != nil {
+ t.Errorf("2.yaml should still exist, got err=%v", err)
+ }
+}
+
+func TestRoomRemoveInvalidDirection(t *testing.T) {
+ dir := t.TempDir()
+ writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n")
+ g := newRemoveGame(t, dir)
+ sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1})
+
+ g.roomRemove(sess, []string{"sideways"})
+ assertOutContains(t, sess, "Invalid direction")
+}
+
+func TestRoomRemoveUsage(t *testing.T) {
+ dir := t.TempDir()
+ writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n")
+ g := newRemoveGame(t, dir)
+ sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1})
+
+ g.roomRemove(sess, nil)
+ assertOutContains(t, sess, "Usage: room remove <direction>")
+}
+
+// TestRoomRemovePreservesExitProps confirms the near-side exit's flags travel
+// onto the repointed A→dir→C edge (and the far side's onto C→oppositeDir→A).
+func TestRoomRemovePreservesExitProps(t *testing.T) {
+ dir := t.TempDir()
+ writeRemoveRoomFile(t, dir, 1, `name: A
+exits:
+ east:
+ room: 2
+ blocked_message: "A rock blocks the way."
+ hidden: true
+`)
+ writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n east: 3\n west: 1\n")
+ writeRemoveRoomFile(t, dir, 3, `name: C
+exits:
+ west:
+ room: 2
+ blocked_message: "The far side is sealed."
+`)
+ g := newRemoveGame(t, dir)
+ sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1})
+
+ g.roomRemove(sess, []string{"east"})
+
+ a := reloadRoom(t, g, 1)
+ if a.Exits[world.East].Room != 3 {
+ t.Errorf("A.Exits[east].Room = %d, want 3", a.Exits[world.East].Room)
+ }
+ if a.Exits[world.East].BlockedMessage != "A rock blocks the way." {
+ t.Errorf("A.Exits[east].BlockedMessage = %q, want preserved", a.Exits[world.East].BlockedMessage)
+ }
+ if !a.Exits[world.East].Hidden {
+ t.Error("A.Exits[east].Hidden should be preserved true")
+ }
+ c := reloadRoom(t, g, 3)
+ if c.Exits[world.West].Room != 1 {
+ t.Errorf("C.Exits[west].Room = %d, want 1", c.Exits[world.West].Room)
+ }
+ if c.Exits[world.West].BlockedMessage != "The far side is sealed." {
+ t.Errorf("C.Exits[west].BlockedMessage = %q, want preserved", c.Exits[world.West].BlockedMessage)
+ }
+}
+
+// TestRoomRemoveRelocatesPlayersInB verifies another session standing in B is
+// moved to A and notified. The caller stays in A.
+func TestRoomRemoveRelocatesPlayersInB(t *testing.T) {
+ dir := t.TempDir()
+ writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n east: 2\n")
+ writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n east: 3\n west: 1\n")
+ writeRemoveRoomFile(t, dir, 3, "name: C\nexits:\n west: 2\n")
+ g := newRemoveGame(t, dir)
+
+ caller := newRemoveSession(&player.Player{Name: "builder", RoomID: 1})
+ bystander := newRemoveSession(&player.Player{Name: "wanderer", RoomID: 2})
+ g.Hub.Add(caller)
+ g.Hub.Add(bystander)
+ g.Hub.EnterRoom(caller, 1)
+ g.Hub.EnterRoom(bystander, 2)
+
+ g.roomRemove(caller, []string{"east"})
+
+ if bystander.Player.RoomID != 1 {
+ t.Errorf("bystander RoomID = %d, want 1 (relocated to A)", bystander.Player.RoomID)
+ }
+ assertOutContains(t, bystander, "The room around you collapses")
+}