diff options
| author | historia <[not public]> | 2026-07-07 05:50:37 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-07-07 05:50:37 -0400 |
| commit | 3f30fa3eec9c2e2acf9a984ccefb9529a25e688a (patch) | |
| tree | b6c146ec7f3dc2ea21dbf8ee7612c3889d93ca1a /internal/game | |
| parent | a51f7a53aa2c487ebf94ba0363038574ae8bb590 (diff) | |
| download | thehouseoficarus-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.go | 3 | ||||
| -rw-r--r-- | internal/game/cmd_room_insert.go | 91 | ||||
| -rw-r--r-- | internal/game/cmd_room_remove.go | 292 | ||||
| -rw-r--r-- | internal/game/cmd_room_remove_test.go | 331 |
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") +} |
