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