From 0b0b25222b3131ada79ff78c28eb8bf07d977deb Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Tue, 15 Sep 2026 15:35:52 -0400 Subject: feat: single-owner game state, transactional lifecycle, durable money on server stop --- internal/game/baseline_race_test.go | 132 ++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 internal/game/baseline_race_test.go (limited to 'internal/game/baseline_race_test.go') diff --git a/internal/game/baseline_race_test.go b/internal/game/baseline_race_test.go new file mode 100644 index 0000000..de5b7b4 --- /dev/null +++ b/internal/game/baseline_race_test.go @@ -0,0 +1,132 @@ +package game + +// Owner-boundary concurrency regressions. These exercise the competing +// runtime entry points the way production does (connection input, ticks, +// disconnects, admin mutations, owner-routed wallet operations) and must +// pass under -race with the single-owner design in place. + +import ( + "fmt" + "sync" + "testing" + "thehouseoficarus/internal/net" +) + +// TestBaselineRaceSessionInputTickDisconnect exercises the three competing +// runtime entry points against each other: connection-goroutine input, the +// ticker goroutine, and hub disconnect callbacks. +func TestBaselineRaceSessionInputTickDisconnect(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) + }() + + // Disconnect bob from a third goroutine while everything else runs. + go func() { + for i := 0; i < 50; i++ { + h.hub.PlayersInRoom(1) + if i == 25 { + h.hub.Remove(bSess) + } + } + }() + + wg.Wait() +} + +// TestBaselineRaceAdminRewriteWhilePlaying hammers Game.RewriteRoomIDs (the +// admin entry point) while players keep moving and ticking. +func TestBaselineRaceAdminRewriteWhilePlaying(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(1) + go func() { + defer wg.Done() + for i := 0; i < 200; i++ { + lines <- fmt.Sprintf("go north") + } + close(lines) + }() + + // Concurrent admin room-ID rewrites targeting the player's live state. + for i := 0; i < 20; i++ { + h.g.RewriteRoomIDs(map[int]int{2: 2 + i + 1, 2 + i: 2 + i + 1}) + } + + wg.Wait() +} + +// TestBaselineRaceCasinoWallet drives concurrent casino activity through +// the owner boundary (as production does): wallet debits/credits from +// multiple players' session tasks plus session input, all on one owner. +func TestBaselineRaceCasinoWallet(t *testing.T) { + h := newHarness(t) + for _, n := range []string{"dave", "erin", "frank"} { + h.seedAccount(n, "pw", false) + p := h.seedCharacter(n, n) + p.Credits = 100000 + } + sessions := make([]*net.Session, 0, 3) + for _, n := range []string{"dave", "erin", "frank"} { + s, _ := h.connect(n) + sessions = append(sessions, s) + } + + var wg sync.WaitGroup + for _, s := range sessions { + wallet := casinoWallet{g: h.g, p: s.Player} + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < 100; i++ { + h.g.RunOnOwner(func() { + wallet.Wager(1) + wallet.PayCredits(1) + }) + } + }() + + // Session input competes with the wallet operations. + lines := h.goInput(s, 1) + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < 100; i++ { + lines <- "look" + } + close(lines) + }() + } + + wg.Wait() + h.barrier() +} -- cgit v1.2.3