aboutsummaryrefslogtreecommitdiff
path: root/internal/admin
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-07-07 00:22:32 -0400
committerhistoria <[not public]>2026-07-07 00:22:32 -0400
commita51f7a53aa2c487ebf94ba0363038574ae8bb590 (patch)
treeb393a8f9e4732b40f6fe8058d086bd631537ad3b /internal/admin
parent9292634f2f8d71a53879ff07e1330c671b207d51 (diff)
downloadthehouseoficarus-a51f7a53aa2c487ebf94ba0363038574ae8bb590.tar.gz
feat: hidden exits (and related flags) work with commands that change room ids. exit code simplified and unified between impassable and blocked exits.
Diffstat (limited to 'internal/admin')
-rw-r--r--internal/admin/api_room_courses.go4
-rw-r--r--internal/admin/api_rooms.go50
-rw-r--r--internal/admin/server.go56
-rw-r--r--internal/admin/static/map.js2
4 files changed, 63 insertions, 49 deletions
diff --git a/internal/admin/api_room_courses.go b/internal/admin/api_room_courses.go
index 86c7cdf..740a102 100644
--- a/internal/admin/api_room_courses.go
+++ b/internal/admin/api_room_courses.go
@@ -574,8 +574,8 @@ func roomExitsMap(m map[string]any) map[string]any {
}
// setCourseBlockedExit adds or replaces an always-blocked exit in direction dir
-// targeting targetRoomID. Always-blocked exits are impassable but render on the map;
-// used to lay out course rooms in visual sequence.
+// targeting targetRoomID. Always-blocked exits are blocked to players but
+// render on the map; used to lay out course rooms in visual sequence.
func setCourseBlockedExit(m map[string]any, dir string, targetRoomID int) {
ex := roomExitsMap(m)
ex[dir] = map[string]any{"room": targetRoomID, "always_blocked": true}
diff --git a/internal/admin/api_rooms.go b/internal/admin/api_rooms.go
index 75f4779..124b093 100644
--- a/internal/admin/api_rooms.go
+++ b/internal/admin/api_rooms.go
@@ -821,37 +821,40 @@ func (s *AdminServer) handleRoomRename(w http.ResponseWriter, r *http.Request) {
s.world.RebuildRoomIndex(s.dataDir)
s.world.ClearRoomState(body.ID)
+ // Remap every genuine room-ID reference (exit targets, trigger/on-enter
+ // room+teleport+despawn_rooms, mob wander_rooms) from the old ID to the
+ // new one across all rooms — including the renamed room itself, so a
+ // self-loop exit is corrected too. Only rooms whose references actually
+ // moved are re-marshaled (preserving untouched files' formatting).
+ idMap := map[int]int{body.ID: body.NewID}
allIDs, _ := listRoomIDs(s.dataDir)
- var exitUpdates int
+ var refUpdates int
for _, otherID := range allIDs {
- if otherID == body.NewID {
- continue
- }
otherRoom, loadErr := s.world.LoadRoom(otherID)
if loadErr != nil {
continue
}
- changed := false
- for _, dir := range world.ExitOrder {
- exit, exists := otherRoom.Exits[dir]
- if !exists || exit.Room != body.ID {
- continue
- }
- exit.Room = body.NewID
- otherRoom.Exits[dir] = exit
- changed = true
- }
- if changed {
- otherPath, pathOk := s.world.GetRoomPath(otherID)
- if pathOk {
- writeYAMLFile(otherPath, otherRoom)
- exitUpdates++
- }
+ if !otherRoom.RewriteRoomIDs(idMap) {
+ continue
+ }
+ otherPath, pathOk := s.world.GetRoomPath(otherID)
+ if !pathOk {
+ continue
}
+ writeYAMLFile(otherPath, otherRoom)
+ refUpdates++
+ }
+
+ // Migrate room-ID-embedded state across all characters (discovered-exit
+ // player flags, RoomID, EnterSeqRoom, MapSymbols, RoomsVisited) so a
+ // rename does not strand players or leave stale hidden_exit_<id>_<dir>
+ // flags pointing at the old room ID.
+ if s.rewriteRoomIDs != nil {
+ s.rewriteRoomIDs(idMap)
}
s.undoStack.Push(ChangeDesc{
- Description: fmt.Sprintf("rename room %d to %d (updated %d exits)", body.ID, body.NewID, exitUpdates),
+ Description: fmt.Sprintf("rename room %d to %d (updated %d refs)", body.ID, body.NewID, refUpdates),
FilePath: oldPath,
NewFilePath: newPath,
OldContent: data,
@@ -941,6 +944,11 @@ func (s *AdminServer) handleResolveDuplicate(w http.ResponseWriter, r *http.Requ
writeJSON(w, map[string]any{"error": "yaml parse failed: " + err.Error()})
return
}
+ // A duplicate file was never in the room index (only one of the pair
+ // is indexed), so no player has visited it and no other room points at
+ // it. Renaming it to an unused ID therefore needs no exit/trigger/wander
+ // reference rewrite and no player-state migration — only its own id
+ // field and filename change.
m["id"] = body.NewID
newContent, err := writeMapAsYAML(newRel, m)
diff --git a/internal/admin/server.go b/internal/admin/server.go
index f0fa58e..6ad20f5 100644
--- a/internal/admin/server.go
+++ b/internal/admin/server.go
@@ -36,22 +36,27 @@ import (
var embedded embed.FS
type AdminServer struct {
- cfg *config.Config
- useTLS bool
- accountStore *player.AccountStore
- world *world.World
- itemStore *item.ItemStore
- objectStore *object.ObjectStore
- mobStore *world.MobStore
- dataDir string
- httpServer *http.Server
- undoStack *UndoStack
- tmpl *template.Template
- cookieSecret []byte
+ cfg *config.Config
+ useTLS bool
+ accountStore *player.AccountStore
+ world *world.World
+ itemStore *item.ItemStore
+ objectStore *object.ObjectStore
+ mobStore *world.MobStore
+ dataDir string
+ httpServer *http.Server
+ undoStack *UndoStack
+ tmpl *template.Template
+ cookieSecret []byte
reloadCourses func()
+ // rewriteRoomIDs migrates room-ID-embedded state across all characters
+ // (online + offline) when a room ID changes. Wired from the Game so the
+ // admin web GUI can trigger the same migration as the swapid command
+ // without the admin package depending on game.
+ rewriteRoomIDs func(idMap map[int]int)
}
-func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStore, w *world.World, is *item.ItemStore, os *object.ObjectStore, ms *world.MobStore, dataDir string, reloadCourses func()) (*AdminServer, error) {
+func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStore, w *world.World, is *item.ItemStore, os *object.ObjectStore, ms *world.MobStore, dataDir string, reloadCourses func(), rewriteRoomIDs func(map[int]int)) (*AdminServer, error) {
secret := make([]byte, 32)
if _, err := rand.Read(secret); err != nil {
return nil, fmt.Errorf("cookie secret: %w", err)
@@ -83,18 +88,19 @@ func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStor
}
s := &AdminServer{
- cfg: cfg,
- useTLS: useTLS,
- accountStore: accountStore,
- world: w,
- itemStore: is,
- objectStore: os,
- mobStore: ms,
- dataDir: dataDir,
- undoStack: NewUndoStack(dataDir),
- tmpl: tmpl,
- cookieSecret: secret,
- reloadCourses: reloadCourses,
+ cfg: cfg,
+ useTLS: useTLS,
+ accountStore: accountStore,
+ world: w,
+ itemStore: is,
+ objectStore: os,
+ mobStore: ms,
+ dataDir: dataDir,
+ undoStack: NewUndoStack(dataDir),
+ tmpl: tmpl,
+ cookieSecret: secret,
+ reloadCourses: reloadCourses,
+ rewriteRoomIDs: rewriteRoomIDs,
}
mux := http.NewServeMux()
diff --git a/internal/admin/static/map.js b/internal/admin/static/map.js
index 8080451..3fdd543 100644
--- a/internal/admin/static/map.js
+++ b/internal/admin/static/map.js
@@ -1296,7 +1296,7 @@ function editExitCondition(dir) {
h += '<div class="form-group"><label>Set Player Flags (key=value, comma separated)</label><input id="exitPFlags" value="' + escAttr(pflagsStr) + '"></div>';
h += '<div class="form-group"><label>Blocked Message</label><textarea id="exitBmsg" rows="2">' + esc(bmsg) + '</textarea></div>';
h += '<div class="form-group"><label><input type="checkbox" id="exitHidden"' + (hidden ? ' checked' : '') + '> Hidden</label></div>';
- h += '<div class="form-group"><label><input type="checkbox" id="exitAlwaysBlocked"' + (alwaysBlocked ? ' checked' : '') + '> Always Blocked (impassable, shown on map)</label></div>';
+ h += '<div class="form-group"><label><input type="checkbox" id="exitAlwaysBlocked"' + (alwaysBlocked ? ' checked' : '') + '> Always Blocked (blocked, shown on map)</label></div>';
h += '<div style="margin-top:10px;border-top:1px solid var(--border);padding-top:8px">';
h += '<label style="font-size:11px;color:#aaa;text-transform:uppercase;letter-spacing:.5px">Conditions</label>';