From 69235cac5b3da5ff0dc20310e7474cabd8e63ba3 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Tue, 15 Sep 2026 20:14:02 -0400 Subject: feat: route all html editor API mutations through single owner task --- internal/admin/live_owner_test.go | 227 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 internal/admin/live_owner_test.go (limited to 'internal/admin/live_owner_test.go') diff --git a/internal/admin/live_owner_test.go b/internal/admin/live_owner_test.go new file mode 100644 index 0000000..7e94a1e --- /dev/null +++ b/internal/admin/live_owner_test.go @@ -0,0 +1,227 @@ +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 +} -- cgit v1.2.3