package game import ( "fmt" "os" "path/filepath" "strconv" "gopkg.in/yaml.v3" "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/net" ) func (g *Game) executeSwapID(sess *net.Session, args []string, rawInput string) { if !g.checkAdmin(sess) { sess.WriteLine("Unknown command.") return } p := sess.Player if p == nil { return } var id1, id2 int switch len(args) { case 1: id1 = p.RoomID var err error id2, err = strconv.Atoi(args[0]) if err != nil { sess.WriteLine("Invalid room ID.") return } case 2: var err error id1, err = strconv.Atoi(args[0]) if err != nil { sess.WriteLine("Invalid room ID.") return } id2, err = strconv.Atoi(args[1]) if err != nil { sess.WriteLine("Invalid room ID.") return } default: sess.WriteLine("Usage: swapid or swapid ") return } if id1 == id2 { sess.WriteLine("Cannot swap a room with itself.") return } path1, ok1 := g.World.GetRoomPath(id1) path2, ok2 := g.World.GetRoomPath(id2) if !ok1 { sess.WriteLine(fmt.Sprintf("Room %d not found.", id1)) return } if !ok2 { sess.WriteLine(fmt.Sprintf("Room %d not found.", id2)) return } // Refuse while non-admin players are online: swapping room IDs relocates // every character whose state embeds those IDs, which is disruptive to // normal play. Admins may stay online (their own state is migrated too). if g.Hub != nil { for _, other := range g.Hub.AllSessions() { if other == sess || other.Player == nil { continue } if !g.checkAdmin(other) { sess.WriteLine("Cannot swap room IDs while non-admin players are online.") return } } } // Swap the two files' contents first. After this, path1 holds the old room // id2's content and path2 holds the old room id1's content. Rebuilding the // index then makes LoadRoom(id1)/LoadRoom(id2) return the swapped content, // so the reference rewrite below operates on the correctly-positioned rooms // and re-marshaling writes an `id:` field matching the new filename. The // swap runs as one journal transaction (like handleRoomRename/the account // lifecycle), so a crash mid-swap is rolled back at the next startup // instead of leaving both rooms at wrong paths with no recovery record. data1, rerr := os.ReadFile(path1) if rerr != nil { sess.WriteLine(fmt.Sprintf("Error reading room %d: %v", id1, rerr)) return } data2, rerr := os.ReadFile(path2) if rerr != nil { sess.WriteLine(fmt.Sprintf("Error reading room %d: %v", id2, rerr)) return } txn := g.newTxn() if rerr := txn.Replace(path1, data2, 0644); rerr != nil { sess.WriteLine(fmt.Sprintf("Error swapping rooms: %v", rerr)) return } if rerr := txn.Replace(path2, data1, 0644); rerr != nil { sess.WriteLine(fmt.Sprintf("Error swapping rooms: %v", rerr)) return } if rerr := txn.Commit(); rerr != nil { sess.WriteLine(fmt.Sprintf("Error swapping rooms: %v", rerr)) return } g.World.RebuildRoomIndex(g.DataDir) // Remap every genuine room-ID reference (exit targets, trigger/on-enter // room+teleport+despawn_rooms, mob wander_rooms) across all rooms. This // replaces the old \b\b regex, which also clobbered unrelated bare // integers (delays, quantities, color indices) and author flag values. // The two swapped rooms are always re-marshaled to correct their `id:` // field; other rooms are re-marshaled only if a reference actually moved. idMap := map[int]int{id1: id2, id2: id1} // Rewrite every room that references the swapped IDs in ONE journal // transaction. The old per-file writeRoomFile loop swallowed errors, so // a crash or a failed write mid-loop left a half-migrated world with no // recovery record. rewriteTxn := g.newTxn() updated := 0 for id := range g.World.RoomIndex() { room, err := g.World.LoadRoom(id) if err != nil { continue } changed := room.RewriteRoomIDs(idMap) if !changed && id != id1 && id != id2 { continue } path, ok := g.World.GetRoomPath(id) if !ok { continue } data, merr := yaml.Marshal(room) if merr != nil { sess.WriteLine(fmt.Sprintf("Error rewriting room %d: %v", id, merr)) return } if rerr := rewriteTxn.Replace(path, data, 0644); rerr != nil { sess.WriteLine(fmt.Sprintf("Error rewriting room %d: %v", id, rerr)) return } updated++ } // Non-room content (courses, modules, triggers, items, objects, mobs) // carries structural room references too. Stage it into the SAME journal // transaction as the room files, so a swap never leaves a course obstacle, // transport destination, or trigger teleport pointing at the wrong room // even if one side's write fails. contentUpdated := 0 for _, sub := range behavior.RoomRefContentDirs { changes, cerr := behavior.RewriteRoomRefsInDir(filepath.Join(g.DataDir, sub), idMap) if cerr != nil { sess.WriteLine(fmt.Sprintf("Error scanning %s for room references: %v", sub, cerr)) return } for _, ch := range changes { if werr := rewriteTxn.Replace(ch.Path, ch.Content, 0644); werr != nil { sess.WriteLine(fmt.Sprintf("Error rewriting %s: %v", ch.Path, werr)) return } contentUpdated++ } } if rerr := rewriteTxn.Commit(); rerr != nil { sess.WriteLine(fmt.Sprintf("Error rewriting room references: %v", rerr)) return } // Full runtime migration: Hub room buckets, casino room keys, world // room-keyed state (ground items, seeded markers, object states), live // mob instances, the room index, and every character's embedded room-ID // state. Without this, a swap leaves live gameplay attached to the old // locations until restart. This runs on the owner (swapid is executed // from the owner command loop) and must not dispatch again. g.MigrateRoomIDsOnOwner(idMap) sess.WriteLine(fmt.Sprintf("Swapped room IDs %d and %d (%d room files, %d content files updated).", id1, id2, updated, contentUpdated)) }