package game // Regressions for the character-file payout-evidence scheme: an owed payout // must never be erased because an unrelated credit change coincidentally // lands on the record's post-payout balance, and a payout whose save became // durable must never be re-applied (double-paid) just because its settled // journal marker was lost. import ( "os" "testing" "thehouseoficarus/internal/casino" "thehouseoficarus/internal/player" ) // newEvidenceHarness builds a minimal Game with just the account store and // economy journal — enough for casinoWallet and recoverEconomy. func newEvidenceHarness(t *testing.T) (*Game, *player.Player) { t.Helper() dir := t.TempDir() if err := os.MkdirAll(dir+"/players/characters", 0o700); err != nil { t.Fatal(err) } g := &Game{ Deps: Deps{AccountStore: player.NewAccountStore(dir)}, Economy: newEconomyJournal(dir), } p := player.New("alice") p.ID = "stable-identity" p.Credits = 90 if err := g.AccountStore.SaveCharacter(p); err != nil { t.Fatal(err) } return g, p } // TestRecoveryReappliesPayoutWhenUnrelatedCreditsMaskTheBalance: a payout // whose save failed leaves an OPEN record at balance 90 (want 110). A later, // unrelated 20-credit gain brings the durable balance to 110 — the record's // CreditsAfter. Recovery must read the character-file evidence (absent: the // save never landed) and re-apply the payout, not settle it as "already // durable" and silently erase the 20 owed credits. func TestRecoveryReappliesPayoutWhenUnrelatedCreditsMaskTheBalance(t *testing.T) { g, p := newEvidenceHarness(t) // Force the payout save to fail without touching its journal. path := g.AccountStore.CharPath(p.Name) backup := path + ".backup" if err := os.Rename(path, backup); err != nil { t.Fatal(err) } if err := os.Mkdir(path, 0o700); err != nil { t.Fatal(err) } w := casinoWallet{g: g, p: p} if err := w.PayCredits(20); err != casino.ErrWalletPersistence { t.Fatalf("PayCredits error = %v, want ErrWalletPersistence", err) } if err := os.Remove(path); err != nil { t.Fatal(err) } if err := os.Rename(backup, path); err != nil { t.Fatal(err) } if p.Credits != 90 { t.Fatalf("failed payout was paid: credits=%d, want 90", p.Credits) } // The unrelated credit change: a shop sale, a bank withdrawal, anything. p.Credits += 20 if err := g.AccountStore.SaveCharacter(p); err != nil { t.Fatal(err) } g.recoverEconomy() loaded, err := g.AccountStore.LoadCharacter(p.Name) if err != nil { t.Fatal(err) } if loaded.Credits != 130 { t.Fatalf("balance after recovery = %d, want 130 (owed payout re-applied once)", loaded.Credits) } if open := g.Economy.loadOpen(); len(open) != 0 { t.Fatalf("records left open after recovery: %+v", open) } } // TestRecoverySettlesDurablePayoutWithoutReapplying: the payout save landed // (the character file records the record ID in open_payouts) but the settled // journal marker was lost in the crash. Recovery must settle from the file // evidence — re-applying would double-pay the win. func TestRecoverySettlesDurablePayoutWithoutReapplying(t *testing.T) { g, p := newEvidenceHarness(t) rec, err := g.Economy.start("payout", p, 0, 20, 90, 110, 0, 0) if err != nil { t.Fatal(err) } // The save landed: balance and evidence both durable. p.Credits = 110 p.OpenPayouts = append(p.OpenPayouts, rec.ID) if err := g.AccountStore.SaveCharacter(p); err != nil { t.Fatal(err) } g.recoverEconomy() loaded, err := g.AccountStore.LoadCharacter(p.Name) if err != nil { t.Fatal(err) } if loaded.Credits != 110 { t.Fatalf("balance after recovery = %d, want 110 (durable payout not re-applied)", loaded.Credits) } if open := g.Economy.loadOpen(); len(open) != 0 { t.Fatalf("records left open after recovery: %+v", open) } }