diff options
| author | historia <[not public]> | 2026-09-18 21:09:05 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-18 21:09:05 -0400 |
| commit | ff04d26ce90d6b411a04a2fb59f7c24ceefb161f (patch) | |
| tree | 754bbd991d147f48822bda519586917e5440916c /internal/game/economy_rollback_regression_test.go | |
| parent | 0f812ccc307ea0550511ccced40b65dfb0cdaa91 (diff) | |
| download | thehouseoficarus-ff04d26ce90d6b411a04a2fb59f7c24ceefb161f.tar.gz | |
fix: keep uncertain payout append from double-paying on recovery
Diffstat (limited to 'internal/game/economy_rollback_regression_test.go')
| -rw-r--r-- | internal/game/economy_rollback_regression_test.go | 89 |
1 files changed, 89 insertions, 0 deletions
diff --git a/internal/game/economy_rollback_regression_test.go b/internal/game/economy_rollback_regression_test.go new file mode 100644 index 0000000..bceb351 --- /dev/null +++ b/internal/game/economy_rollback_regression_test.go @@ -0,0 +1,89 @@ +package game + +// Rollback-failure regressions for the durable money journal. When a journal +// append fails, the invariant was historically "append failed means no record +// bytes remain". A failed rollback (the truncate, or the sync that makes it +// durable) breaks that invariant: the record can survive while the caller +// believes the append failed. The wallet's journal-unavailable payout fallback +// must then still record the attempted record's ID as open-payout evidence, or +// recovery re-applies the surviving payout and pays the player twice. + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" +) + +// TestAppendRollbackFailureIsUncertain: /dev/full accepts the open but fails +// the write with ENOSPC and the rollback truncate with EINVAL, so the append +// must report that the record MAY have survived. +func TestAppendRollbackFailureIsUncertain(t *testing.T) { + if _, err := os.Stat("/dev/full"); err != nil { + t.Skipf("/dev/full is not available: %v", err) + } + j := newEconomyJournal(t.TempDir()) + j.path = "/dev/full" + + err := j.append(economyRecord{ID: "uncertain", Kind: "payout", Status: "open"}) + if !errors.Is(err, errJournalAppendUncertain) { + t.Fatalf("append rollback failure = %v, want errJournalAppendUncertain", err) + } +} + +// TestFallbackPayoutRecordsEvidenceForSurvivingRecord: a payout whose journal +// append fails and whose rollback cannot be proven must leave open-payout +// evidence in the character file. A surviving record is then settled from that +// evidence at the next startup instead of being re-applied. +func TestFallbackPayoutRecordsEvidenceForSurvivingRecord(t *testing.T) { + g, p := newEvidenceHarness(t) + + // Break the journal append: point it at its own directory, which cannot + // be opened for append or truncation. The wallet takes the file-only + // fallback. + journalPath := g.Economy.path + g.Economy.path = filepath.Dir(journalPath) + w := casinoWallet{g: g, p: p} + if err := w.PayCredits(20); err != nil { + t.Fatalf("fallback payout failed: %v", err) + } + if p.Credits != 110 { + t.Fatalf("fallback payout not applied: credits=%d, want 110", p.Credits) + } + if len(p.OpenPayouts) != 1 { + t.Fatalf("fallback payout recorded %d open-payout evidence IDs, want 1: %v", + len(p.OpenPayouts), p.OpenPayouts) + } + id := p.OpenPayouts[0] + + // Simulate the failed append having survived in the journal (the exact + // state a failed rollback produces). Recovery must use the file evidence + // and settle it, not re-apply it. + g.Economy.path = journalPath + rec := economyRecord{ + ID: id, Player: p.Name, PlayerID: p.ID, Kind: "payout", Tracked: true, + Credit: 20, Status: "open", CreditsBefore: 90, CreditsAfter: 110, + } + line, err := json.Marshal(rec) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(journalPath, append(line, '\n'), 0o600); 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 (surviving payout settled from evidence, not re-applied)", + loaded.Credits) + } + if open := g.Economy.mustLoadOpen(t); len(open) != 0 { + t.Fatalf("records left open after recovery: %+v", open) + } +} |
