From 9192d94dea6be5498ab080f3e2df5667af871d7a Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Wed, 16 Sep 2026 14:41:15 -0400 Subject: fix: casino room eviction, session close race fix, open-payout recovery evidence --- internal/admin/api_files.go | 28 +++++++++++++++++++++++++++ internal/admin/api_rooms.go | 8 ++++++++ internal/admin/api_rooms_casino_evict_test.go | 27 ++++++++++++++++++++++++++ 3 files changed, 63 insertions(+) create mode 100644 internal/admin/api_rooms_casino_evict_test.go (limited to 'internal/admin') diff --git a/internal/admin/api_files.go b/internal/admin/api_files.go index f2ef928..ea75249 100644 --- a/internal/admin/api_files.go +++ b/internal/admin/api_files.go @@ -6,6 +6,7 @@ import ( "net/http" "os" "path/filepath" + "strconv" "strings" ) @@ -20,6 +21,32 @@ func resolveDataPath(dataDir, relPath string) (string, error) { return filepath.Join(dataDir, clean), nil } +// evictDeletedRoomFile drops cached room state and any cached in-room games +// when the raw file manager deletes a room YAML (rooms//.yaml). +// Best-effort: only numeric .yaml stems under rooms/ count as room files. +func (s *AdminServer) evictDeletedRoomFile(relPath string) { + dir, base := filepath.Split(relPath) + idStr, ok := strings.CutSuffix(base, ".yaml") + if !ok || idStr == "" { + return + } + dir = strings.TrimSuffix(dir, "/") + if dir == "rooms" || strings.HasPrefix(dir, "rooms"+string(filepath.Separator)) { + id, err := strconv.Atoi(idStr) + if err != nil { + return + } + s.world.RebuildRoomIndex(s.dataDir) + s.world.ClearRoomState(id) + if s.mobStore != nil { + s.mobStore.RemoveMobsInRoom(id) + } + if s.evictRoom != nil { + s.evictRoom(id) + } + } +} + func (s *AdminServer) handleFileRename(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) @@ -204,6 +231,7 @@ func (s *AdminServer) handleFiles(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]any{"error": "delete error: " + err.Error()}) return } + s.evictDeletedRoomFile(relPath) s.undoStack.Push(ChangeDesc{ Description: "Deleted file " + relPath, FilePath: fullPath, diff --git a/internal/admin/api_rooms.go b/internal/admin/api_rooms.go index 3c96d53..01b7b9a 100644 --- a/internal/admin/api_rooms.go +++ b/internal/admin/api_rooms.go @@ -320,6 +320,11 @@ func (s *AdminServer) handleRoomByID(w http.ResponseWriter, r *http.Request) { s.world.RebuildRoomIndex(s.dataDir) s.world.ClearRoomState(id) + // Drop any cached in-room games (tables/machines) and refund their + // outstanding wagers — same eviction semantics as bulk delete. + if s.evictRoom != nil { + s.evictRoom(id) + } allIDs, _ := listRoomIDs(s.dataDir) var extraFiles []ExtraFile @@ -1178,6 +1183,9 @@ func (s *AdminServer) handleResolveDuplicate(w http.ResponseWriter, r *http.Requ return } s.world.RebuildRoomIndex(s.dataDir) + // No casino eviction: a duplicate-ID file was never in the room index, + // so no cached game is keyed to it (games are keyed by the indexed + // room's ID, which survives here untouched). s.undoStack.Push(ChangeDesc{ Description: fmt.Sprintf("delete duplicate room file %s", body.Path), FilePath: absPath, diff --git a/internal/admin/api_rooms_casino_evict_test.go b/internal/admin/api_rooms_casino_evict_test.go new file mode 100644 index 0000000..426c13a --- /dev/null +++ b/internal/admin/api_rooms_casino_evict_test.go @@ -0,0 +1,27 @@ +package admin + +// Regression: deleting a single room through the editor API must evict the +// room's cached casino games and refund their outstanding wagers, exactly +// like the bulk-delete path does. Skipping eviction strands seated players' +// wagers in games whose room no longer exists. + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestRoomDeleteEvictsCasinoGames(t *testing.T) { + s := newTestAdminServer(t, map[int]string{1: "id: 1\nname: Room\n"}) + var evicted []int + s.evictRoom = func(id int) { evicted = append(evicted, id) } + + rec := httptest.NewRecorder() + s.handleRoomByID(rec, httptest.NewRequest(http.MethodDelete, "/api/rooms/1", nil)) + if _, ok := s.world.GetRoomPath(1); ok { + t.Fatalf("room was not removed: %s", rec.Body.String()) + } + if len(evicted) != 1 || evicted[0] != 1 { + t.Fatalf("room deletion evicted %v, want [1] (outstanding wagers were not resolved)", evicted) + } +} -- cgit v1.2.3