aboutsummaryrefslogtreecommitdiff
path: root/internal/admin
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-16 21:35:55 -0400
committerhistoria <[not public]>2026-09-16 21:35:55 -0400
commitfea2cc6839ec3ee17940a2dea69622dc6f4deabf (patch)
tree9d893525ca20d2cc920e9e866f84f71b605d8ae9 /internal/admin
parent35ddd0ad2215760bb1d3dc7724480f179ea33fa6 (diff)
downloadthehouseoficarus-fea2cc6839ec3ee17940a2dea69622dc6f4deabf.tar.gz
fix: unify crafting ingredient resolution; fix superheat alternatives, quantity gates, and dropped combine byproducts
Diffstat (limited to 'internal/admin')
-rw-r--r--internal/admin/api_room_courses.go7
-rw-r--r--internal/admin/live_owner_test.go3
-rw-r--r--internal/admin/rebuild_after_undo_regression_test.go88
-rw-r--r--internal/admin/server.go18
4 files changed, 115 insertions, 1 deletions
diff --git a/internal/admin/api_room_courses.go b/internal/admin/api_room_courses.go
index bebacff..5a33e6a 100644
--- a/internal/admin/api_room_courses.go
+++ b/internal/admin/api_room_courses.go
@@ -321,6 +321,13 @@ func (s *AdminServer) handleRoomAttachCourse(w http.ResponseWriter, r *http.Requ
failJSON(w, "missing course_id")
return
}
+ // Course IDs join into file paths under data/courses, so an unchecked
+ // id ("../../x") could write course YAML outside the data directory.
+ // Same rule as every other entity route (see validEntityID).
+ if !validEntityID(body.CourseID) {
+ failJSON(w, "invalid course_id")
+ return
+ }
m, _, path, err := s.loadCourseMap(body.CourseID)
if err != nil {
failJSON(w, "course not found: "+body.CourseID)
diff --git a/internal/admin/live_owner_test.go b/internal/admin/live_owner_test.go
index 7e94a1e..e975681 100644
--- a/internal/admin/live_owner_test.go
+++ b/internal/admin/live_owner_test.go
@@ -219,7 +219,8 @@ func newEditorHarness(t *testing.T) (*editorHarness, *gamenet.Session) {
if err := g.AccountStore.SaveCharacter(p); err != nil {
t.Fatal(err)
}
- sess := &gamenet.Session{Conn: discardConn{}, Account: acc, Player: p, State: gamenet.StateGame}
+ sess := &gamenet.Session{Conn: discardConn{}, Account: acc, Player: p}
+ sess.SetState(gamenet.StateGame)
hub.Add(sess)
hub.EnterRoom(sess, 1)
g.Ticks.Start(2)
diff --git a/internal/admin/rebuild_after_undo_regression_test.go b/internal/admin/rebuild_after_undo_regression_test.go
new file mode 100644
index 0000000..134b281
--- /dev/null
+++ b/internal/admin/rebuild_after_undo_regression_test.go
@@ -0,0 +1,88 @@
+package admin
+
+// Regressions for the editor's undo/redo and course-attach paths: file paths
+// built from request bodies must stay inside the data directory, and every
+// disk mutation must refresh the matching runtime state (course index,
+// casino games) or gameplay keeps serving stale data.
+
+import (
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// TestCourseAttachRejectsTraversalCourseID: course_id joins into a file path
+// under data/courses, so a traversal id ("../../outside") must be rejected
+// instead of writing course YAML outside the data directory.
+func TestCourseAttachRejectsTraversalCourseID(t *testing.T) {
+ root := t.TempDir()
+ dir := filepath.Join(root, "data")
+ if err := os.MkdirAll(filepath.Join(dir, "courses"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ target := filepath.Join(root, "outside.yaml")
+ original := "name: Outside\n"
+ if err := os.WriteFile(target, []byte(original), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ s := &AdminServer{dataDir: dir, undoStack: NewUndoStack(dir)}
+
+ rr := httptest.NewRecorder()
+ s.handleRoomAttachCourse(rr, httptest.NewRequest("POST", "/api/rooms/1/attach-course",
+ strings.NewReader(`{"course_id":"../../outside","after_index":-1}`)), 1)
+
+ if got, err := os.ReadFile(target); err != nil || string(got) != original {
+ t.Fatalf("path traversal modified a file outside the data directory; response=%s file=%q err=%v", rr.Body.String(), got, err)
+ }
+}
+
+// TestUndoOfCourseChangeReloadsRuntimeCourses: undoing a course edit restores
+// the on-disk YAML, so the runtime course index must reload like the course
+// API handlers do — otherwise gameplay keeps traversing the stale course.
+func TestUndoOfCourseChangeReloadsRuntimeCourses(t *testing.T) {
+ s := newTestAdminServer(t, map[int]string{1: "id: 1\nname: Room\n"})
+ if err := os.MkdirAll(filepath.Join(s.dataDir, "courses"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ reloads := 0
+ s.reloadCourses = func() { reloads++ }
+
+ s.handleCourses(httptest.NewRecorder(), httptest.NewRequest("POST", "/api/courses",
+ strings.NewReader(`{"id":"course","name":"Course","obstacles":[{"room_id":1,"verb":"climb"}]}`)))
+ if reloads != 1 {
+ t.Fatalf("course creation reload count: %d, want 1", reloads)
+ }
+
+ s.doUndo(httptest.NewRecorder(), httptest.NewRequest("POST", "/api/undo/undo", nil))
+ if _, err := os.Stat(s.courseFilePath("course")); !os.IsNotExist(err) {
+ t.Fatal("undo did not delete the course file")
+ }
+ if reloads != 2 {
+ t.Fatalf("undo restored the disk but did not reload courses: got %d callbacks, want 2", reloads)
+ }
+}
+
+// TestUndoOfRoomCreateEvictsCasinoGames: undoing a room creation deletes the
+// room file, so its cached casino games must be evicted (and their outstanding
+// wagers refunded through the wired callback) exactly like the direct-delete
+// API does. Skipping eviction strands seated players' wagers in games whose
+// room no longer exists.
+func TestUndoOfRoomCreateEvictsCasinoGames(t *testing.T) {
+ content := "id: 1\nname: Room\n"
+ s := newTestAdminServer(t, map[int]string{1: content})
+ evictions := 0
+ s.evictRoom = func(id int) { evictions++ }
+ path, _ := s.world.GetRoomPath(1)
+
+ s.undoStack.Push(ChangeDesc{Description: "create room", FilePath: path, NewContent: []byte(content), IsCreate: true})
+ s.doUndo(httptest.NewRecorder(), httptest.NewRequest("POST", "/api/undo/undo", nil))
+
+ if _, err := os.Stat(path); !os.IsNotExist(err) {
+ t.Fatal("undo did not delete the room")
+ }
+ if evictions != 1 {
+ t.Fatalf("undo removed the room without evicting/refunding its casino games: got %d callbacks, want 1", evictions)
+ }
+}
diff --git a/internal/admin/server.go b/internal/admin/server.go
index 2c8681a..12d090d 100644
--- a/internal/admin/server.go
+++ b/internal/admin/server.go
@@ -720,6 +720,13 @@ func (s *AdminServer) rebuildAfterUndo(change *ChangeDesc) {
if s.mobStore != nil {
s.mobStore.RemoveMobsInRoom(id)
}
+ // The room no longer exists, so its cached casino games
+ // (tables/machines) must be evicted like the direct-delete
+ // API does: outstanding wagers are refunded through the
+ // wired game callback before the cache entries drop.
+ if s.evictRoom != nil {
+ s.evictRoom(id)
+ }
}
}
}
@@ -728,6 +735,17 @@ func (s *AdminServer) rebuildAfterUndo(change *ChangeDesc) {
for _, p := range changeFiles(change) {
s.invalidateEntityCacheForPath(p)
}
+
+ // Course definitions changed on disk (create/update/delete via undo or
+ // redo): the runtime course index must reload, exactly like the course
+ // API handlers do, or gameplay keeps traversing the stale course.
+ for _, p := range changeFiles(change) {
+ if rel, err := filepath.Rel(filepath.Join(s.dataDir, "courses"), p); err == nil &&
+ !strings.HasPrefix(rel, "..") && rel != "." {
+ s.reloadCoursesIfWired()
+ break
+ }
+ }
}
func (s *AdminServer) invalidateEntityCaches(entityType string) {