aboutsummaryrefslogtreecommitdiff
path: root/internal/game/readiness_regression_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game/readiness_regression_test.go')
-rw-r--r--internal/game/readiness_regression_test.go283
1 files changed, 283 insertions, 0 deletions
diff --git a/internal/game/readiness_regression_test.go b/internal/game/readiness_regression_test.go
new file mode 100644
index 0000000..752b755
--- /dev/null
+++ b/internal/game/readiness_regression_test.go
@@ -0,0 +1,283 @@
+package game
+
+// Readiness regressions for the account-rename, disconnect, and durable-money
+// paths reviewed before world/casino expansion: a transport disconnect must
+// be race-free, renames must not break ownership or strand owed payouts,
+// reserved-loot pickups must never destroy items, and recovery failure paths
+// must retry or surface instead of silently dropping money.
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+// TestDisconnectRaceIsFreeAndNeverLost drives DisconnectTick (owner) while a
+// transport goroutine arms the disconnect (exactly net.Server.handleSession's
+// deferred Hub.Remove path). Guards the atomic disconnect flags: a plain
+// bool/int pair is a data race and can lose the disconnect entirely.
+func TestDisconnectRaceIsFreeAndNeverLost(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice", "pw", false)
+ h.seedCharacter("alice", "alice")
+ sess, _ := h.connect("alice")
+
+ started, finished := make(chan struct{}), make(chan struct{})
+ go func() {
+ h.g.RunOnOwner(func() {
+ close(started)
+ for i := 0; i < 10000; i++ {
+ h.g.DisconnectTick()
+ }
+ })
+ close(finished)
+ }()
+ <-started
+ h.hub.Remove(sess) // transport goroutine; never on the owner
+ <-finished
+
+ if sessions := h.g.Hub.AllSessions(); len(sessions) != 0 {
+ t.Fatalf("disconnect lost under the race: %d sessions remain", len(sessions))
+ }
+}
+
+// TestReservedPickupDoesNotDestroyItems: picking up a ground stack that
+// shares a name with another player's reserved stack must take only the
+// takeable stock — never half-remove the ground with nothing delivered.
+func TestReservedPickupDoesNotDestroyItems(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice", "pw", false)
+ p := h.seedCharacter("alice", "alice")
+ writeSeedFile(t, filepath.Join(h.dir, "items", "chips.yaml"), []byte("id: chips\nname: chips\nstackable: true\n"))
+ sess := &net.Session{Conn: newChanConn(), Player: p, State: net.StateGame}
+ h.g.RunOnOwner(func() {
+ h.g.ItemStore.Reload(h.dir)
+ if _, err := h.g.ItemStore.Load("chips"); err != nil {
+ t.Error(err)
+ return
+ }
+ h.g.World.AddGroundItem(1, "chips", 10)
+ // A reserved stack of the same item sits after the takeable one:
+ // the old mid-loop abort destroyed the already-removed half.
+ h.g.World.AddReservedItem(1, "chips", 10, "bob")
+ h.g.doGet(sess, "chips")
+ total := countChips(p) + h.g.World.GroundItems(1)["chips"]
+ if total != 20 {
+ t.Errorf("pickup destroyed items: inventory+ground=%d, want 20", total)
+ }
+ })
+}
+
+// TestOwedPayoutSurvivesCharacterRename: economy records resolve their
+// subject by the character's STABLE ID, so renaming the character between
+// the crash and recovery still delivers the owed payout.
+func TestOwedPayoutSurvivesCharacterRename(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("acct", "pw", false)
+ p := h.seedCharacter("alice", "acct")
+ h.g.RunOnOwner(func() {
+ p.ID = "stable-char-id" // production characters carry a stable ID
+ p.Credits = 90
+ if err := h.g.AccountStore.SaveCharacter(p); err != nil {
+ t.Error(err)
+ return
+ }
+ if _, err := h.g.Economy.start("payout", p, 0, 20, 90, 110, 0, 0); err != nil {
+ t.Error(err)
+ return
+ }
+ if err := h.g.renameCharacterTxn("acct", "alice", "alicia"); err != nil {
+ t.Error(err)
+ return
+ }
+ h.g.recoverEconomy()
+ loaded, err := h.g.AccountStore.LoadCharacter("alicia")
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ if loaded.Credits != 110 {
+ t.Errorf("renamed character never receives owed payout: credits=%d, want 110", loaded.Credits)
+ }
+ })
+}
+
+// TestAccountRenamePreservesAccessAndOwnership: renaming an account
+// rewrites the owned character files' owner field in the same transaction
+// and republishes stale sessions (still carrying the old cached name) so
+// both sessions stay authenticated and keep character access.
+func TestAccountRenamePreservesAccessAndOwnership(t *testing.T) {
+ h := newHarness(t)
+ s1, s2 := setupAcct(t, h)
+ h.g.RunOnOwner(func() {
+ h.g.handleRenameAccount(s1, "renamed")
+ if !h.g.AccountStore.AccountExists("renamed") {
+ t.Error("rename did not succeed")
+ return
+ }
+ if s1.Account.Name != "renamed" || s2.Account.Name != "renamed" {
+ t.Errorf("sessions remain bound to %q/%q, want renamed/renamed", s1.Account.Name, s2.Account.Name)
+ }
+ if !h.g.isMemberOf("renamed", "alice") {
+ t.Error("renamed account cannot access its own character")
+ }
+ })
+}
+
+// TestSettledSurrenderDoesNotRefundChipStake: a settlement payout — even a
+// partial one like a surrendered hand paying half a chip stake — proves the
+// round resolved, so recovery must not ALSO return the chip stake in kind.
+func TestSettledSurrenderDoesNotRefundChipStake(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice", "pw", false)
+ p := h.seedCharacter("alice", "alice")
+ h.g.RunOnOwner(func() {
+ p.Credits = 100
+ p.SetInvSlot(0, &player.InventorySlot{ItemID: "chips", Quantity: 10})
+ w := casinoWallet{g: h.g, p: p}
+ if _, err := w.Wager(10); err != nil {
+ t.Error(err)
+ return
+ }
+ // Surrender settles for half the stake; the crash lands before the
+ // resolution marker.
+ if err := w.PayCredits(5); err != nil {
+ t.Error(err)
+ return
+ }
+ h.g.recoverEconomy()
+ loaded, err := h.g.AccountStore.LoadCharacter("alice")
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ if loaded.Credits != 105 || countChips(loaded) != 0 {
+ t.Errorf("settled surrender recovered %d credits / %d chips; want 105 / 0", loaded.Credits, countChips(loaded))
+ }
+ })
+}
+
+// TestRecoverySaveFailureRetriesOnNextStartup: a TRANSIENT save failure
+// during recovery must not re-anchor the owed record to unreadable
+// checkpoints — the record keeps its original checkpoints and a later
+// recovery (after the disk is fixed) re-applies the owed payout.
+func TestRecoverySaveFailureRetriesOnNextStartup(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice", "pw", false)
+ p := h.seedCharacter("alice", "alice")
+ charDir := filepath.Join(h.dir, "players", "characters")
+ t.Cleanup(func() { os.Chmod(charDir, 0755) })
+ h.g.RunOnOwner(func() {
+ p.Credits = 90
+ if err := h.g.AccountStore.SaveCharacter(p); err != nil {
+ t.Error(err)
+ return
+ }
+ if _, err := h.g.Economy.start("payout", p, 0, 20, 90, 110, 0, 0); err != nil {
+ t.Error(err)
+ return
+ }
+ if err := os.Chmod(charDir, 0500); err != nil {
+ t.Error(err)
+ return
+ }
+ h.g.recoverEconomy() // every save fails
+ if err := os.Chmod(charDir, 0755); err != nil {
+ t.Error(err)
+ return
+ }
+ h.g.recoverEconomy() // retry with a healthy disk
+ loaded, err := h.g.AccountStore.LoadCharacter("alice")
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ if loaded.Credits != 110 {
+ t.Errorf("after fixing the disk and retrying: %d credits, want 110; open=%+v", loaded.Credits, h.g.Economy.loadOpen())
+ }
+ })
+}
+
+// TestLostResolutionMarkerSurfacesAndRefunds: a resolution marker that
+// cannot be journaled (persistent append failure) leaves the stake OPEN —
+// never silently forgotten — and recovery then applies the documented
+// refund-on-lost-state policy (the player is never the loser of a lost
+// marker).
+func TestLostResolutionMarkerSurfacesAndRefunds(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice", "pw", false)
+ p := h.seedCharacter("alice", "alice")
+ h.g.RunOnOwner(func() {
+ p.Credits = 100
+ w := casinoWallet{g: h.g, p: p}
+ if _, err := w.Wager(10); err != nil {
+ t.Error(err)
+ return
+ }
+ path := h.g.Economy.path
+ h.g.Economy.path = h.dir // persistent append failure
+ w.ResolveStakes()
+ h.g.Economy.path = path
+ if open := h.g.Economy.openStakes(economyKey(p)); len(open) != 1 {
+ t.Errorf("unresolvable stake was silently dropped: open=%d", len(open))
+ }
+ h.g.recoverEconomy()
+ loaded, err := h.g.AccountStore.LoadCharacter("alice")
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ if loaded.Credits != 100 {
+ t.Errorf("after recovery: credits=%d, want 100 (stake refunded; marker lost)", loaded.Credits)
+ }
+ if open := h.g.Economy.loadOpen(); len(open) != 0 {
+ t.Errorf("records left open after recovery: %+v", open)
+ }
+ })
+}
+
+// TestUnjournalablePayoutRefundsStake: a payout that cannot even be
+// journaled (persistent append failure) is not announced as paid, the
+// balance is unchanged, and recovery applies the documented fallback: the
+// stake is refunded. The unrecorded win beyond the stake cannot be
+// protected, but the player never loses money and no win is announced that
+// is not durable.
+func TestUnjournalablePayoutRefundsStake(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice", "pw", false)
+ p := h.seedCharacter("alice", "alice")
+ h.g.RunOnOwner(func() {
+ p.Credits = 100
+ w := casinoWallet{g: h.g, p: p}
+ if _, err := w.Wager(10); err != nil {
+ t.Error(err)
+ return
+ }
+ path := h.g.Economy.path
+ h.g.Economy.path = h.dir
+ err := w.PayCredits(20)
+ h.g.Economy.path = path
+ if err == nil {
+ t.Error("injected write failure did not fail")
+ return
+ }
+ if p.Credits != 90 {
+ t.Errorf("failed payout mutated the balance: %d", p.Credits)
+ }
+ h.g.recoverEconomy()
+ loaded, err := h.g.AccountStore.LoadCharacter("alice")
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ if loaded.Credits != 100 {
+ t.Errorf("stake refund after unjournalable payout: credits=%d, want 100", loaded.Credits)
+ }
+ if open := h.g.Economy.loadOpen(); len(open) != 0 {
+ t.Errorf("open records: %+v", open)
+ }
+ })
+}