package admin import ( "fmt" "io" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "strings" "sync" "sync/atomic" "testing" "time" "thehouseoficarus/internal/casino" "thehouseoficarus/internal/config" "thehouseoficarus/internal/game" gamenet "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" ) // Use the real authenticated HTTP router, not direct handler calls: production // must commit files, migrate runtime state AND rebuild caches in one owner task. func TestAdminEditorAtomicWithGameplay(t *testing.T) { h, sess := newEditorHarness(t) h.g.RunOnOwner(func() { sess.Player.RoomID = 2 h.hub.EnterRoom(sess, 2) h.g.AccountStore.SaveCharacter(sess.Player) h.g.Casino.JoinMachine(2, casino.MachineConfig{ID: "slots", Game: casino.GameSlots, MinBet: 1}, "alice") }) // Pause after the handler has committed files but before runtime migration. // A tick/input/observer queued here must not see the torn intermediate state. midMutation := make(chan struct{}, 1) releaseMigration := make(chan struct{}) var releaseOnce sync.Once release := func() { releaseOnce.Do(func() { close(releaseMigration) }) } defer release() var pause atomic.Bool migrate := func(ids map[int]int) { if pause.Load() { midMutation <- struct{}{} <-releaseMigration } h.g.MigrateRoomIDsOnOwner(ids) } s, err := NewServer(config.Default(), false, h.g.AccountStore, h.g.World, h.g.ItemStore, h.g.ObjectStore, h.g.MobStore, h.dir, h.g.CourseStore.ReloadAll, migrate, h.g.EvictCasinoRoom, h.g.RebuildCraftIndex, filepath.Join(h.dir, "config.yaml"), h.g.RunAdminOnOwner) if err != nil { t.Fatal(err) } form := url.Values{"account": {"alice"}, "password": {"pw"}} login := httptest.NewRequest("POST", "/login", strings.NewReader(form.Encode())) login.Header.Set("Content-Type", "application/x-www-form-urlencoded") loginRec := httptest.NewRecorder() s.ServeHTTP(loginRec, login) cookies := loginRec.Result().Cookies() if loginRec.Code != http.StatusFound || len(cookies) == 0 { t.Fatalf("login: %d %s", loginRec.Code, loginRec.Body.String()) } request := func(path, body string) error { r := httptest.NewRequest("POST", path, strings.NewReader(body)) r.AddCookie(cookies[0]) rec := httptest.NewRecorder() s.ServeHTTP(rec, r) if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"error"`) { return fmt.Errorf("%s: %d %s", path, rec.Code, rec.Body.String()) } return nil } var observations atomic.Int64 // Called ONLY on the owner. Both tick and session observers assert the // disk/index/player/Hub/casino snapshot agrees, not merely that it is race-free. check := func() { observations.Add(1) id := sess.Player.RoomID path, ok := h.g.World.GetRoomPath(id) if !ok { t.Errorf("player in unindexed room %d", id) return } if _, err := os.Stat(path); err != nil { t.Errorf("player room %d missing on disk: %v", id, err) } if room, err := h.g.World.LoadRoom(id); err != nil || room.ID != id { t.Errorf("room file/index disagrees with player %d: %v", id, err) } if players := h.hub.PlayersInRoom(id); len(players) != 1 || players[0] != sess { t.Errorf("Hub disagrees with player room %d", id) } if machine, ok := h.g.Casino.MachineFor("alice"); !ok || machine.RoomID != id { t.Errorf("casino disagrees with player room %d", id) } if saved, err := h.g.AccountStore.LoadCharacter("alice"); err != nil || saved.RoomID != id { t.Errorf("saved character disagrees with player room %d: %v", id, err) } } tickID := h.g.SubscribeTicks(1, func() bool { h.g.RunTickSystems() check() return true }) defer h.g.Ticks.Unsubscribe(tickID) pause.Store(true) requestDone := make(chan error, 1) go func() { requestDone <- request("/api/rooms/rename", `{"id":2,"new_id":4}`) }() select { case <-midMutation: case <-time.After(5 * time.Second): t.Fatal("rename did not reach migration (nested owner dispatch?)") } observed := make(chan struct{}) go func() { h.g.RunOnOwner(func() { h.g.HandleSession(sess, "look") check() close(observed) }) }() select { case <-observed: t.Error("gameplay ran between file commit and runtime migration") case <-time.After(20 * time.Millisecond): } pause.Store(false) release() select { case err := <-requestDone: if err != nil { t.Fatal(err) } case <-time.After(5 * time.Second): t.Fatal("rename deadlocked") } <-observed // Repeat real rename/undo/redo while input and ticks compete for ownership. inputDone := make(chan struct{}) go func() { defer close(inputDone) for i := 0; i < 100; i++ { h.g.HandleSessionOnOwner(sess, "look") h.g.RunOnOwner(check) } }() for i := 0; i < 5; i++ { for _, path := range []string{"/api/undo/undo", "/api/undo/redo"} { if err := request(path, ""); err != nil { t.Error(err) } } } <-inputDone h.g.RunOnOwner(check) if observations.Load() < 100 { t.Fatalf("too few gameplay observations: %d", observations.Load()) } } type editorHarness struct { g *game.Game hub *gamenet.Hub dir string } type discardConn struct{} func (discardConn) ReadMessage() (string, error) { return "", io.EOF } func (discardConn) Write(p []byte) (int, error) { return len(p), nil } func (discardConn) Close() error { return nil } func (discardConn) SetWriteDeadline(time.Time) error { return nil } func (discardConn) SetEcho(bool) error { return nil } func newEditorHarness(t *testing.T) (*editorHarness, *gamenet.Session) { t.Helper() dir := t.TempDir() for _, subdir := range []string{"rooms", "players/accounts", "players/characters"} { if err := os.MkdirAll(filepath.Join(dir, subdir), 0755); err != nil { t.Fatal(err) } } for id, contents := range map[int]string{ 1: "id: 1\nname: Central\nexits:\n north:\n room: 2\n", 2: "id: 2\nname: North\nexits:\n south:\n room: 1\n", } { if err := os.WriteFile(filepath.Join(dir, "rooms", fmt.Sprintf("%d.yaml", id)), []byte(contents), 0644); err != nil { t.Fatal(err) } } colors := &config.ColorsConfig{} g := game.New(dir, colors, colors, config.ValidationConfig{}, 1) hub := gamenet.NewHub() g.SetHub(hub) stopped := make(chan struct{}) g.SetTransportStop(func() { close(stopped) }) t.Cleanup(func() { g.RequestShutdown("test complete") select { case <-stopped: case <-time.After(5 * time.Second): t.Error("game shutdown timed out") } }) hash, err := player.HashPassword("pw") if err != nil { t.Fatal(err) } acc := &player.Account{Name: "alice", PasswordHash: hash, Admin: true, Characters: []string{"alice"}} p := player.New("alice") p.Account, p.RoomID = "alice", 1 if err := g.AccountStore.SaveAccount(acc); err != nil { t.Fatal(err) } if err := g.AccountStore.SaveCharacter(p); err != nil { t.Fatal(err) } sess := &gamenet.Session{Conn: discardConn{}, Account: acc, Player: p, State: gamenet.StateGame} hub.Add(sess) hub.EnterRoom(sess, 1) g.Ticks.Start(2) return &editorHarness{g: g, hub: hub, dir: dir}, sess }