aboutsummaryrefslogtreecommitdiff
path: root/internal/game/cmd_room_remove.go
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/cmd_room_remove.go
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/cmd_room_remove.go')
-rw-r--r--internal/game/cmd_room_remove.go292
1 files changed, 292 insertions, 0 deletions
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
+}