diff options
Diffstat (limited to 'internal/game/readiness_regression_test.go')
| -rw-r--r-- | internal/game/readiness_regression_test.go | 283 |
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) + } + }) +} |
