From 6ef40a5739f1723ec26cc32dfc48fe764f776dca Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Wed, 16 Sep 2026 19:57:45 -0400 Subject: fix: durable payouts survive journal failure, cap bank withdrawals, hash passwords off the owner loop. --- internal/game/readiness_regression_test.go | 66 +++++++++++++++++++----------- 1 file changed, 41 insertions(+), 25 deletions(-) (limited to 'internal/game/readiness_regression_test.go') diff --git a/internal/game/readiness_regression_test.go b/internal/game/readiness_regression_test.go index 7fa2b2e..3d21390 100644 --- a/internal/game/readiness_regression_test.go +++ b/internal/game/readiness_regression_test.go @@ -196,17 +196,19 @@ func TestRecoverySaveFailureRetriesOnNextStartup(t *testing.T) { 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()) + t.Errorf("after fixing the disk and retrying: %d credits, want 110; open=%+v", loaded.Credits, h.g.Economy.mustLoadOpen(t)) } }) } -// 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) { +// TestResolvedStakeEvidenceSurvivesJournalFailure: a resolution marker +// that cannot be journaled (persistent append failure) leaves the stake +// OPEN — never silently forgotten — and records the resolution in the +// character file as durable evidence. Recovery then settles the stake +// WITHOUT refunding it: the round genuinely resolved, and the file is the +// proof. When the file save also fails, no evidence exists and recovery +// applies the documented refund-on-lost-state policy instead. +func TestResolvedStakeEvidenceSurvivesJournalFailure(t *testing.T) { h := newHarness(t) h.seedAccount("alice") p := h.seedCharacter("alice", "alice") @@ -224,28 +226,40 @@ func TestLostResolutionMarkerSurfacesAndRefunds(t *testing.T) { 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() + // The resolution is recorded durably in the character file. 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 len(loaded.ResolvedStakes) != 1 { + t.Errorf("resolved-stake evidence not recorded: %+v", loaded.ResolvedStakes) + return + } + h.g.recoverEconomy() + loaded, err = h.g.AccountStore.LoadCharacter("alice") + if err != nil { + t.Error(err) + return + } + if loaded.Credits != 90 { + t.Errorf("after recovery: credits=%d, want 90 (round resolved; evidence prevents a refund)", loaded.Credits) } - if open := h.g.Economy.loadOpen(); len(open) != 0 { + if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 { t.Errorf("records left open after recovery: %+v", open) } + if len(loaded.ResolvedStakes) != 0 { + t.Errorf("stale resolved-stake evidence not pruned: %+v", loaded.ResolvedStakes) + } }) } -// 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) { +// TestUnjournalablePayoutPaidThroughCharacterFile: a payout that cannot be +// journaled (persistent append failure) is still made durable through the +// character file — the save is the commit point, so the win is real and +// announced. The resolved stake is recorded as file evidence so recovery +// neither refunds the stake (double pay) nor drops the round. +func TestUnjournalablePayoutPaidThroughCharacterFile(t *testing.T) { h := newHarness(t) h.seedAccount("alice") p := h.seedCharacter("alice", "alice") @@ -260,23 +274,25 @@ func TestUnjournalablePayoutRefundsStake(t *testing.T) { 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") + if err != nil { + t.Errorf("character-file fallback failed: %v", err) return } - if p.Credits != 90 { - t.Errorf("failed payout mutated the balance: %d", p.Credits) + if p.Credits != 110 { + t.Errorf("fallback payout did not land: credits=%d, want 110", p.Credits) } + // As the games do after a successful settlement: resolve the stake. + w.ResolveStakes() 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 loaded.Credits != 110 { + t.Errorf("after recovery: credits=%d, want 110 (win durable once; no stake refund on top)", loaded.Credits) } - if open := h.g.Economy.loadOpen(); len(open) != 0 { + if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 { t.Errorf("open records: %+v", open) } }) -- cgit v1.2.3