aboutsummaryrefslogtreecommitdiff
path: root/internal/admin/live_owner_test.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-15 20:14:02 -0400
committerhistoria <[not public]>2026-09-15 20:14:02 -0400
commit69235cac5b3da5ff0dc20310e7474cabd8e63ba3 (patch)
treece96c9506531e219c157f29ac6a6dc53de1df15b /internal/admin/live_owner_test.go
parent6f3c43aca62a7d33a19f14f6ad7c16891952673a (diff)
downloadthehouseoficarus-69235cac5b3da5ff0dc20310e7474cabd8e63ba3.tar.gz
feat: route all html editor API mutations through single owner task
Diffstat (limited to 'internal/admin/live_owner_test.go')
-rw-r--r--internal/admin/live_owner_test.go227
1 files changed, 227 insertions, 0 deletions
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
+}