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 ") 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, SetGlobalFlags: aExit.SetGlobalFlags, 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, SetGlobalFlags: cOppExit.SetGlobalFlags, 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\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 }