aboutsummaryrefslogtreecommitdiff
path: root/internal/game/owner_concurrency_test.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-15 15:35:52 -0400
committerhistoria <[not public]>2026-09-15 15:35:52 -0400
commit0b0b25222b3131ada79ff78c28eb8bf07d977deb (patch)
tree2ceaef5203595044f66a8f00064cd8e017f8f48d /internal/game/owner_concurrency_test.go
parent396c3facb8287b9a34076cae8f3d7b300d366d99 (diff)
downloadthehouseoficarus-0b0b25222b3131ada79ff78c28eb8bf07d977deb.tar.gz
feat: single-owner game state, transactional lifecycle, durable money on server stop
Diffstat (limited to 'internal/game/owner_concurrency_test.go')
-rw-r--r--internal/game/owner_concurrency_test.go154
1 files changed, 154 insertions, 0 deletions
diff --git a/internal/game/owner_concurrency_test.go b/internal/game/owner_concurrency_test.go
new file mode 100644
index 0000000..58e426b
--- /dev/null
+++ b/internal/game/owner_concurrency_test.go
@@ -0,0 +1,154 @@
+package game
+
+// Owner-loop concurrency regressions. These exercise the competing runtime
+// entry points (connection input, ticks, disconnects, admin mutations) the
+// way production does and must pass under -race now that all live-state
+// mutation executes on the owner goroutine. The pre-owner baselines live in
+// baseline_race_test.go behind the baselinerace build tag.
+
+import (
+ "sync"
+ "testing"
+ "time"
+)
+
+// TestOwnerSerializesEntryPoints runs session input, the ticker goroutine,
+// and hub disconnects concurrently.
+func TestOwnerSerializesEntryPoints(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice", "pw", false)
+ h.seedAccount("bob", "pw", false)
+ h.seedCharacter("alice", "alice")
+ h.seedCharacter("bob", "bob")
+
+ aSess, _ := h.connect("alice")
+ bSess, _ := h.connect("bob")
+
+ aLines := h.goInput(aSess, 1)
+ bLines := h.goInput(bSess, 1)
+
+ var wg sync.WaitGroup
+ wg.Add(2)
+ go func() {
+ defer wg.Done()
+ for i := 0; i < 200; i++ {
+ aLines <- "look"
+ }
+ close(aLines)
+ }()
+ go func() {
+ defer wg.Done()
+ for i := 0; i < 200; i++ {
+ bLines <- "say hi"
+ }
+ close(bLines)
+ }()
+
+ go func() {
+ for i := 0; i < 50; i++ {
+ h.hub.PlayersInRoom(1)
+ if i == 25 {
+ h.hub.Remove(bSess)
+ }
+ }
+ }()
+
+ wg.Wait()
+ h.barrier()
+}
+
+// TestOwnerAdminRewriteWhilePlaying runs Game.RewriteRoomIDs (admin entry)
+// concurrently with live movement and ticking.
+func TestOwnerAdminRewriteWhilePlaying(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("carol", "pw", false)
+ h.seedCharacter("carol", "carol")
+ sess, _ := h.connect("carol")
+ lines := h.goInput(sess, 1)
+
+ var wg sync.WaitGroup
+ wg.Add(2)
+ go func() {
+ defer wg.Done()
+ for i := 0; i < 100; i++ {
+ lines <- "look"
+ }
+ close(lines)
+ }()
+ go func() {
+ defer wg.Done()
+ // Rewrite 2->4, then back; exercise both directions while the
+ // player commands run.
+ for i := 0; i < 10; i++ {
+ h.g.RunOnOwner(func() { h.g.RewriteRoomIDs(map[int]int{2: 4}) })
+ h.g.RunOnOwner(func() { h.g.RewriteRoomIDs(map[int]int{4: 2}) })
+ }
+ }()
+ wg.Wait()
+ h.barrier()
+}
+
+// TestSlowClientDoesNotStallTheWorld proves the write-queue boundary: a
+// session whose transport blocks forever on Write must not prevent other
+// players from receiving output and progressing.
+func TestSlowClientDoesNotStallTheWorld(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice", "pw", false)
+ h.seedAccount("bob", "pw", false)
+ h.seedCharacter("alice", "alice")
+ h.seedCharacter("bob", "bob")
+
+ // Alice's writer goroutine blocks at the transport (full socket buffer
+ // equivalent); her queue absorbs or disconnects her, but never blocks
+ // the owner goroutine.
+ aSess, aConn := h.connect("alice")
+ bSess, bConn := h.connect("bob")
+ aConn.writeGate = make(chan struct{})
+
+ bLines := h.goInput(bSess, 1)
+
+ for i := 0; i < 300; i++ {
+ aSess.WriteLine("spam for the stalled client")
+ }
+ // Bob keeps playing and must still see output while alice is stuck.
+ bLines <- "say hello"
+ deadline := time.Now().Add(3 * time.Second)
+ for time.Now().Before(deadline) {
+ if bConn.Contains("hello") {
+ return
+ }
+ time.Sleep(5 * time.Millisecond)
+ }
+ t.Fatalf("bob's output never arrived while alice's client was stalled")
+}
+
+// TestOwnerDisconnectDuringInput removes a session from a separate goroutine
+// while its input goroutine is mid-stream, proving the remove callback
+// (save + registry cleanup) serializes with session input on the owner.
+func TestOwnerDisconnectDuringInput(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("dave", "pw", false)
+ h.seedCharacter("dave", "dave")
+ sess, conn := h.connect("dave")
+
+ lines := h.goInput(sess, 1)
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ for i := 0; i < 50; i++ {
+ lines <- "look"
+ }
+ close(lines)
+ }()
+ // Hard-remove while input is streaming; the onRemove callback mutates
+ // player state and must not race with HandleSession.
+ for i := 0; i < 50; i++ {
+ h.hub.PlayersInRoom(1)
+ if i == 10 {
+ h.hub.HardRemove(sess)
+ }
+ }
+ <-done
+ h.barrier()
+ _ = conn
+}