package game import ( "fmt" "os" "path/filepath" "strconv" "strings" "gopkg.in/yaml.v3" "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/net" "thehouseoficarus/internal/world" ) func (g *Game) executeDig(sess *net.Session, args []string, rawInput string) { if !g.checkAdmin(sess) { sess.WriteLine("Unknown command.") return } p := sess.Player if p == nil { return } if len(args) == 0 { sess.WriteLine("Dig where? (n/s/e/w/ne/nw/se/sw/u/d)") 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 } curPath, ok := g.World.GetRoomPath(p.RoomID) if !ok { sess.WriteLine("Error: can't find current room file.") return } curRoom, err := g.World.LoadRoom(p.RoomID) if err != nil { sess.WriteLine("Error loading current room.") return } if _, hasExit := curRoom.Exits[dir]; hasExit { sess.WriteLine(fmt.Sprintf("There is already an exit to the %s.", dir)) return } if conflictID := g.findGridConflict(p.RoomID, dir); conflictID != 0 { conflictRoom, err := g.World.LoadRoom(conflictID) conflictName := fmt.Sprintf("#%d", conflictID) if err == nil { conflictName = fmt.Sprintf("#%d (%s)", conflictID, conflictRoom.Name) } oppositeDir := world.OppositeExit[dir] curRoom.Exits[dir] = world.ExitDef{Room: conflictID} curData, err := yaml.Marshal(curRoom) if err != nil { sess.WriteLine("Error updating current room YAML.") return } if err := writeRoomFile(curPath, curData); err != nil { sess.WriteLine("Error writing current room file.") return } if conflictRoom != nil { if _, exists := conflictRoom.Exits[oppositeDir]; !exists { conflictRoom.Exits[oppositeDir] = world.ExitDef{Room: p.RoomID} conflictPath, ok := g.World.GetRoomPath(conflictID) if ok { conflictData, cerr := yaml.Marshal(conflictRoom) if cerr == nil { if werr := writeRoomFile(conflictPath, conflictData); werr != nil { sess.WriteLine(fmt.Sprintf("Warning: could not link the opposite exit in room %d: %v", conflictID, werr)) } } } } } sess.WriteLine(fmt.Sprintf("That spot is already occupied by %s. Created a two-way link instead.", conflictName)) oldRoom := p.RoomID p.ClearMoveState() if g.Combat.Get(p.Name) != nil { g.stopCombat(p.Name) } p.Action = nil g.cancelRest(p.Name) g.cancelSequences(p.Name) if ss, ok := g.safespot.Get(p.Name); ok { g.forceLeaveSafespot(sess, p, &ss, "") } p.RoomID = conflictID p.HazardTimer = 0 p.Stats.RecordRoomVisit(conflictID) g.saveCharacter(p) g.World.SeedGroundItems(conflictID) g.seedRoomMobs(conflictID) g.seedRoomObjects(conflictID) if g.Hub != nil { for _, other := range g.Hub.PlayersInRoom(oldRoom) { if other != sess { other.WriteLine(fmt.Sprintf("%s digs to the %s and vanishes.", p.Name, dir)) } } g.Hub.EnterRoom(sess, conflictID) for _, other := range g.Hub.PlayersInRoom(conflictID) { if other != sess { other.WriteLine(fmt.Sprintf("%s appears from a freshly dug tunnel.", p.Name)) } } } sess.WriteLine(fmt.Sprintf("You step through the new link to %s.", conflictName)) g.doLook(sess) g.runEnterSteps(sess, conflictID) g.checkAggro(sess) return } newID, err := findNextRoomID(filepath.Dir(curPath), g.World.RoomIndex()) if err != nil { sess.WriteLine("Error scanning room directory.") return } var name string if len(args) > 1 { name = strings.Join(args[1:], " ") } else { name = fmt.Sprintf("Room #%d", newID) } oppositeDir := world.OppositeExit[dir] dirPath := filepath.Dir(curPath) newPath := filepath.Join(dirPath, strconv.Itoa(newID)+".yaml") newRoom := &world.Room{ Name: name, Description: behavior.DescList{ {Text: "A featureless room."}, }, Exits: map[world.ExitDir]world.ExitDef{ oppositeDir: {Room: p.RoomID}, }, } data, err := yaml.Marshal(newRoom) if err != nil { sess.WriteLine("Error creating room YAML.") return } if err := os.MkdirAll(dirPath, 0755); err != nil { sess.WriteLine("Error creating directory.") return } if err := writeRoomFile(newPath, data); err != nil { sess.WriteLine("Error writing room file.") return } g.World.AddRoomPath(newID, newPath) curRoom.Exits[dir] = world.ExitDef{Room: newID} curData, err := yaml.Marshal(curRoom) if err != nil { sess.WriteLine("Error updating current room YAML.") return } if err := writeRoomFile(curPath, curData); err != nil { sess.WriteLine("Error writing current room file.") return } oldRoom := p.RoomID p.ClearMoveState() if g.Combat.Get(p.Name) != nil { g.stopCombat(p.Name) } p.Action = nil g.cancelRest(p.Name) g.cancelSequences(p.Name) if ss, ok := g.safespot.Get(p.Name); ok { g.forceLeaveSafespot(sess, p, &ss, "") } p.RoomID = newID p.HazardTimer = 0 p.Stats.RecordRoomVisit(newID) g.saveCharacter(p) g.World.SeedGroundItems(newID) g.seedRoomMobs(newID) g.seedRoomObjects(newID) if g.Hub != nil { for _, other := range g.Hub.PlayersInRoom(oldRoom) { if other != sess { other.WriteLine(fmt.Sprintf("%s digs to the %s and vanishes.", p.Name, dir)) } } g.Hub.EnterRoom(sess, newID) for _, other := range g.Hub.PlayersInRoom(newID) { if other != sess { other.WriteLine(fmt.Sprintf("%s appears from a freshly dug tunnel.", p.Name)) } } } sess.WriteLine(fmt.Sprintf("You dig %s and create Room #%d (%s).", dir, newID, name)) g.doLook(sess) g.runEnterSteps(sess, newID) g.checkAggro(sess) } // findNextRoomID returns the first free room ID at or above localDir's // minimum, checked against every room ID in the world (globallyUsed). The // admin allocator (admin/id_alloc.go) does the same. A directory-scoped scan // could hand out an ID that already exists in another area, and // World.AddRoomPath would then silently shadow that room for the rest of the // process while leaving a duplicate file on disk. func findNextRoomID(localDir string, globallyUsed map[int]bool) (int, error) { entries, err := os.ReadDir(localDir) if err != nil { return 0, err } used := make(map[int]bool, len(globallyUsed)+len(entries)) for id := range globallyUsed { used[id] = true } minID := 0 first := true for _, entry := range entries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yaml") { continue } idStr := strings.TrimSuffix(entry.Name(), ".yaml") id, err := strconv.Atoi(idStr) if err != nil { continue } used[id] = true if first || id < minID { minID = id first = false } } start := 1 if !first { start = minID } for id := start; ; id++ { if !used[id] { return id, nil } } } func (g *Game) buildGridFrom(fromRoomID int) (coord map[int][3]int, roomAt map[[3]int]int) { roomIndex := g.World.RoomIndex() rg := world.BuildGrid(fromRoomID, func(id int) (*world.Room, bool) { r, err := g.World.LoadRoom(id) if err != nil { return nil, false } return r, true }, func(id int) bool { return roomIndex[id] }, nil, nil, ) return rg.Coord, rg.RoomAt } func (g *Game) findGridConflict(fromRoomID int, dir world.ExitDir) int { delta, ok := world.DirectionDeltas3D[dir] if !ok { return 0 } _, roomAt := g.buildGridFrom(fromRoomID) targetCoord := [3]int{delta[0], delta[1], delta[2]} if occupier, ok := roomAt[targetCoord]; ok && occupier != fromRoomID { return occupier } return 0 }