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/cmd_room_remove.go | |
| 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/cmd_room_remove.go')
| -rw-r--r-- | internal/game/cmd_room_remove.go | 292 |
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 +} |
