diff options
| author | historia <[not public]> | 2026-09-15 19:55:21 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-15 19:55:21 -0400 |
| commit | 6f3c43aca62a7d33a19f14f6ad7c16891952673a (patch) | |
| tree | 0b0665ef624ef442df87ec7131129aaecc3420bd /internal/game | |
| parent | cd95de60364aba8f5bdf8ec86f7234a9903eacc8 (diff) | |
| download | thehouseoficarus-6f3c43aca62a7d33a19f14f6ad7c16891952673a.tar.gz | |
fix: outstanding-stake ledger, owner backpressure, editor state preservation
Diffstat (limited to 'internal/game')
| -rw-r--r-- | internal/game/casino.go | 60 | ||||
| -rw-r--r-- | internal/game/casino_forfeit_regression_test.go | 61 | ||||
| -rw-r--r-- | internal/game/core_login_account.go | 14 | ||||
| -rw-r--r-- | internal/game/economy.go | 241 | ||||
| -rw-r--r-- | internal/game/economy_recovery_regression_test.go | 169 | ||||
| -rw-r--r-- | internal/game/economy_shutdown_test.go | 16 | ||||
| -rw-r--r-- | internal/game/owner.go | 36 | ||||
| -rw-r--r-- | internal/game/owner_regression_test.go | 68 |
8 files changed, 607 insertions, 58 deletions
diff --git a/internal/game/casino.go b/internal/game/casino.go index 3edb26b..8ba9097 100644 --- a/internal/game/casino.go +++ b/internal/game/casino.go @@ -20,10 +20,15 @@ type casinoWallet struct { // Wager debits the stake durably. Order of operations: durable journal // record FIRST (with balance checkpoints), then the in-memory mutation, -// then the character save, then the settlement marker. A failed save -// reverts the in-memory mutation and voids the record: no charge is kept -// in memory, and the error distinguishes persistence failure from -// insufficient funds. +// then the character save, then the record is registered as an OUTSTANDING +// STAKE. It is NOT settled here: it stays open until the round resolves +// (casino.Wallet.ResolveStakes) or is refunded (RefundOutstanding). An open +// record at startup recovery means the round state was lost in a crash and +// the stake is refunded — the documented refund-on-crash policy. +// +// A failed save reverts the in-memory mutation and voids the record: no +// charge is kept in memory, and the error distinguishes persistence failure +// from insufficient funds. func (w casinoWallet) Wager(amount int) (casino.Wager, error) { if amount <= 0 { return casino.Wager{}, casino.ErrInsufficientFunds @@ -57,7 +62,9 @@ func (w casinoWallet) Wager(amount int) (casino.Wager, error) { w.g.Economy.void(rec) return casino.Wager{}, casino.ErrWalletPersistence } - w.g.Economy.settle(rec) + // The record stays open as an outstanding stake until the round + // resolves; recovery refunds open stakes after a crash. + w.g.Economy.registerStake(rec) return casino.Wager{Total: amount, Chips: chips, Credits: credits}, nil } @@ -90,6 +97,49 @@ func (w casinoWallet) PayCredits(amount int) error { return nil } +// ResolveStakes marks every outstanding stake for the player settled: the +// round resolved (won, lost, pushed, or forfeited), so startup recovery +// must no longer refund it. +func (w casinoWallet) ResolveStakes() { + w.g.Economy.ResolveStakes(w.p.Name) +} + +// RefundOutstanding returns every outstanding stake to the player: chips +// come back as chips, credits through the durable payout path. The stakes +// are resolved only once the return is durable; on failure the chip return +// is rolled back out of memory and the open records drive recovery instead. +func (w casinoWallet) RefundOutstanding() (chips, credits int, err error) { + for _, rec := range w.g.Economy.openStakes(w.p.Name) { + chips += rec.Chips + credits += rec.Credit + } + if chips <= 0 && credits <= 0 { + w.ResolveStakes() + return 0, 0, nil + } + if chips > 0 { + w.g.giveChips(w.p, chips) + } + if credits > 0 { + if err := w.PayCredits(credits); err != nil { + if chips > 0 { + // Roll the chip return back out of memory: the durable + // refund will re-apply both amounts from the open records. + w.p.RemoveItem("chips", chips) + } + return chips, credits, err + } + } else if chips > 0 { + // A chips-only refund needs its own durable save. + if err := w.g.AccountStore.SaveCharacter(w.p); err != nil { + w.p.RemoveItem("chips", chips) + return chips, 0, casino.ErrWalletPersistence + } + } + w.ResolveStakes() + return chips, credits, nil +} + // casinoAffordable is the most a player can wager: chips are spent first, // then credits. func casinoAffordable(p *player.Player) int { return p.Credits + countChips(p) } diff --git a/internal/game/casino_forfeit_regression_test.go b/internal/game/casino_forfeit_regression_test.go new file mode 100644 index 0000000..9ad8016 --- /dev/null +++ b/internal/game/casino_forfeit_regression_test.go @@ -0,0 +1,61 @@ +package game + +// Disconnect-forfeit regressions: a player who disconnects mid-round +// forfeits the committed stake, so startup recovery must NOT refund it; a +// stake left outstanding by a genuine crash still is. + +import ( + "testing" + + "thehouseoficarus/internal/casino" +) + +// TestDisconnectForfeitsMachineStake: disconnecting mid-spin forfeits the +// stake (the machine frees the seat immediately); recovery must not refund +// it on the next startup. +func TestDisconnectForfeitsMachineStake(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice", "pw", false) + p := h.seedCharacter("alice", "alice") + h.g.RunOnOwner(func() { + p.Credits = 100 + h.g.Casino.JoinMachine(1, casino.MachineConfig{ID: "slots", Game: casino.GameSlots, SpinTicks: 50}, "alice") + h.g.Casino.SetMachineBet(1, "slots", "alice", 10, casinoWallet{g: h.g, p: p}) + // The disconnect path forfeits the in-flight spin. + h.g.Casino.MarkDisconnected("alice") + }) + h.barrier() + + restarted := harnessFromDir(t, h.dir) + loaded, err := restarted.g.AccountStore.LoadCharacter("alice") + if err != nil { + t.Fatal(err) + } + if loaded.Credits != 90 { + t.Fatalf("credits after forfeited spin = %d, want 90 (disconnect forfeits)", loaded.Credits) + } +} + +// TestDisconnectForfeitsBlackjackStake: same policy at a table: the seat +// frees up and the stake is not refunded by recovery. +func TestDisconnectForfeitsBlackjackStake(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice", "pw", false) + p := h.seedCharacter("alice", "alice") + h.g.RunOnOwner(func() { + p.Credits = 100 + h.g.Casino.Join(1, casino.TableConfig{ID: "bj", Game: casino.GameBlackjack, MaxPlayers: 2}, "alice") + h.g.Casino.Bet(1, "bj", "alice", "", 10, casinoWallet{g: h.g, p: p}) + h.g.Casino.MarkDisconnected("alice") + }) + h.barrier() + + restarted := harnessFromDir(t, h.dir) + loaded, err := restarted.g.AccountStore.LoadCharacter("alice") + if err != nil { + t.Fatal(err) + } + if loaded.Credits != 90 { + t.Fatalf("credits after forfeited hand = %d, want 90 (disconnect forfeits)", loaded.Credits) + } +} diff --git a/internal/game/core_login_account.go b/internal/game/core_login_account.go index a846f4d..fbd9e3a 100644 --- a/internal/game/core_login_account.go +++ b/internal/game/core_login_account.go @@ -2,6 +2,7 @@ package game import ( "fmt" + "log" "strings" "thehouseoficarus/internal/color" @@ -62,6 +63,19 @@ func (g *Game) handlePassword(sess *net.Session, input string) { sess.Write("Account name: ") return } + // Transparent upgrade: legacy single-pass SHA-256 hashes rehash to + // Argon2id after a successful verification. A failed save keeps the + // legacy hash and retries on the next login. + if player.NeedsRehash(acc.PasswordHash) { + if hash, hashErr := player.HashPassword(input); hashErr == nil { + acc.PasswordHash = hash + if err := g.AccountStore.SaveAccount(acc); err != nil { + log.Printf("login: password rehash save for %s: %v", acc.Name, err) + } + } else { + log.Printf("login: password rehash for %s: %v", acc.Name, hashErr) + } + } sess.Conn.SetEcho(true) sess.Account = &player.Account{ Name: acc.Name, diff --git a/internal/game/economy.go b/internal/game/economy.go index 90c24ed..fcc0010 100644 --- a/internal/game/economy.go +++ b/internal/game/economy.go @@ -2,20 +2,37 @@ package game // The economy journal: durable, idempotent money movement. Every casino // wager and payout writes a journal record BEFORE the balance mutation, -// then the character save makes the balance durable, then the record is -// settled. A crash at any point leaves either: +// then the character save makes the balance durable. +// +// Two kinds of records exist: +// +// - "wager": a committed stake. The record stays OPEN while the stake is +// outstanding (the round is in flight); the game resolves it with a +// settled marker when the round resolves (won, lost, pushed, or +// forfeited). At startup, open wager records are refunded — the round +// state was lost in the crash, so the stake returns to the player. +// - "payout": money movement (winnings, pushes, refunds). The record +// closes when the balance mutation is durable; an open payout at +// startup is owed money and is re-applied. +// +// A crash at any point therefore leaves either: // - an open record with the character file at its pre-transaction state // (crash before save) — recovery settles it as void (debit) or applies // it (payout, which the player is owed); or -// - an open record with the character file at its post-transaction state -// (crash after save) — recovery refunds open debit records (the round -// state was lost in the crash, so the stake returns) and voids payout -// records that are already durable. +// - an open wager with the file at its post-transaction state and no +// later payout evidence (the round state was lost in the crash) — the +// stake is refunded; or +// - an open payout (crash after the journal write, before the save) — +// the owed amount is re-applied. +// +// Records whose checkpoints match neither balance state after compensating +// earlier records are ambiguous: they are kept open and surfaced for +// operator inspection rather than silently settled or voided. // // The reducer folds journal lines by record ID (later lines win), so // settlement markers are idempotent appends. Recovery runs once at startup // before any session can join; compaction rewrites the journal keeping only -// open records (normally none). +// open records (outstanding stakes and unresolved payouts). import ( "bufio" @@ -48,12 +65,16 @@ type economyRecord struct { type economyJournal struct { path string + // stakes indexes outstanding wager records per player name. All + // journal mutations happen on the game owner (or during startup + // recovery before any session connects), so no lock is needed. + stakes map[string][]*economyRecord } func newEconomyJournal(dataDir string) *economyJournal { dir := filepath.Join(dataDir, "economy") _ = os.MkdirAll(dir, 0700) - return &economyJournal{path: filepath.Join(dir, "journal.jsonl")} + return &economyJournal{path: filepath.Join(dir, "journal.jsonl"), stakes: make(map[string][]*economyRecord)} } func newEconomyRecordID() string { @@ -119,17 +140,72 @@ func (j *economyJournal) void(rec *economyRecord) { _ = j.append(*rec) } +// registerStake tracks an outstanding wager record for its player. Wager +// records stay open (refundable) until the round resolves. +func (j *economyJournal) registerStake(rec *economyRecord) { + j.stakes[rec.Player] = append(j.stakes[rec.Player], rec) +} + +// openStakes returns the outstanding stake records for a player. +func (j *economyJournal) openStakes(player string) []*economyRecord { + return j.stakes[player] +} + +// ResolveStakes closes every outstanding stake for a player with a settled +// marker per record: the round resolved, so recovery must not refund them. +func (j *economyJournal) ResolveStakes(player string) { + for _, rec := range j.stakes[player] { + rec.Status = "settled" + _ = j.append(*rec) + } + delete(j.stakes, player) +} + +// reloadStakes rebuilds the outstanding-stake index from the journal file: +// open wager records remain tracked (and refundable/resolvable) after +// recovery compaction. +func (j *economyJournal) reloadStakes() { + j.stakes = make(map[string][]*economyRecord) + for _, fr := range j.loadFolded() { + if fr.rec.Kind == "wager" && fr.rec.Status == "open" { + rec := fr.rec + j.registerStake(&rec) + } + } +} + // loadOpen folds the journal and returns the still-open records. func (j *economyJournal) loadOpen() []*economyRecord { + var open []*economyRecord + for _, fr := range j.loadFolded() { + if fr.rec.Status == "open" { + rec := fr.rec + open = append(open, &rec) + } + } + return open +} + +// foldedRecord is the latest state of one record ID together with the +// order (line position of its latest write) it occupied in the journal. +type foldedRecord struct { + rec economyRecord + seq int +} + +// loadFolded folds the journal (latest line per record ID wins) and +// returns the surviving records in journal append order. +func (j *economyJournal) loadFolded() []foldedRecord { f, err := os.Open(j.path) if err != nil { return nil } defer f.Close() - latest := map[string]*economyRecord{} + latest := map[string]*foldedRecord{} + var order []*foldedRecord scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) - for scanner.Scan() { + for seq := 0; scanner.Scan(); seq++ { line := scanner.Bytes() if len(line) == 0 { continue @@ -141,15 +217,20 @@ func (j *economyJournal) loadOpen() []*economyRecord { // line can be torn. continue } - latest[rec.ID] = &rec - } - var open []*economyRecord - for _, rec := range latest { - if rec.Status == "open" { - open = append(open, rec) + if existing, ok := latest[rec.ID]; ok { + existing.rec = rec + existing.seq = seq + } else { + fr := &foldedRecord{rec: rec, seq: seq} + latest[rec.ID] = fr + order = append(order, fr) } } - return open + folded := make([]foldedRecord, 0, len(order)) + for _, fr := range order { + folded = append(folded, *fr) + } + return folded } // compact rewrites the journal keeping only still-open records. @@ -230,32 +311,112 @@ func addCreditsChecked(a, b int) (int, error) { // recoverEconomy compensates open records from the last crash. It is called // once from New, before any session connects. +// +// Recovery runs in two phases so checkpoints stay meaningful: +// +// 1. Open PAYOUT records are processed in append order against the evolving +// durable balance: file at the pre-payout balance → the payout is +// re-applied (the player is owed it); at the post-payout balance → it is +// already durable and settles; anything else stays open and is surfaced +// for operator inspection (a liability is never silently dropped). +// 2. Open WAGER records are processed NEWEST FIRST (each refund restores +// the balance an earlier record's checkpoints refer to). A record at +// its pre-debit state (credits AND chips) never became durable → void; +// at its post-debit state → the stake was charged but the round state +// was lost in the crash → the stake is refunded, unless a payout record +// written later proves the round resolved (a win/push/refund already +// returned the stake inside that payout) → settled with no refund. +// Anything else stays open for operator inspection. func (g *Game) recoverEconomy() { if g.Economy == nil { return } - records := g.Economy.loadOpen() - compensated := 0 - for _, rec := range records { - p, err := g.AccountStore.LoadCharacter(rec.Player) + folded := g.Economy.loadFolded() + players := make(map[string]*player.Player) + loadPlayer := func(name string) *player.Player { + if p, ok := players[name]; ok { + return p + } + p, err := g.AccountStore.LoadCharacter(name) if err != nil { // Character file unreadable: leave the record open so a later // attempt (or operator inspection) can resolve it; do not // silently drop money. - fmt.Fprintf(os.Stderr, "economy recovery: load %s: %v\n", rec.Player, err) + fmt.Fprintf(os.Stderr, "economy recovery: load %s: %v\n", name, err) + return nil + } + players[name] = p + return p + } + hasLaterPayout := func(name string, seq int) bool { + for _, fr := range folded { + if fr.rec.Kind == "payout" && fr.rec.Player == name && fr.rec.Status != "void" && fr.seq > seq { + return true + } + } + return false + } + compensated := 0 + + // Phase 1 — payouts, in append order. + for i := range folded { + rec := &folded[i].rec + if rec.Kind != "payout" || rec.Status != "open" { + continue + } + p := loadPlayer(rec.Player) + if p == nil { + continue + } + switch { + case p.Credits == rec.CreditsBefore: + // The payout was lost in the crash: the player is owed it. + if err := g.payCreditsDurable(p, rec.Credit); err != nil { + fmt.Fprintf(os.Stderr, "economy recovery: payout save %s: %v\n", p.Name, err) + continue + } + g.Economy.settle(rec) + compensated++ + case p.Credits == rec.CreditsAfter: + g.Economy.settle(rec) + default: + fmt.Fprintf(os.Stderr, "economy recovery: ambiguous payout %s for %s (credits %d, want before %d or after %d) — kept open for operator inspection\n", + rec.ID, rec.Player, p.Credits, rec.CreditsBefore, rec.CreditsAfter) + } + } + + // Phase 2 — outstanding wagers, newest first. + var openWagers []int + for i := range folded { + if folded[i].rec.Kind == "wager" && folded[i].rec.Status == "open" { + openWagers = append(openWagers, i) + } + } + for k := len(openWagers) - 1; k >= 0; k-- { + rec := &folded[openWagers[k]].rec + resolvedByPayout := hasLaterPayout(rec.Player, folded[openWagers[k]].seq) + p := loadPlayer(rec.Player) + if p == nil { continue } + creditsNow, chipsNow := p.Credits, countChips(p) switch { - case rec.Kind == "wager" && p.Credits == rec.CreditsBefore: + case creditsNow == rec.CreditsBefore && chipsNow == rec.ChipsBefore: // The debit never became durable: void it. g.Economy.void(rec) - case rec.Kind == "wager" && p.Credits == rec.CreditsAfter: + case creditsNow == rec.CreditsAfter && chipsNow == rec.ChipsAfter: + if resolvedByPayout { + // A later payout record proves the round resolved (the win, + // push, or refund already returned this stake inside that + // payout). Only the resolution marker was lost. + g.Economy.settle(rec) + continue + } // The debit is durable but the round was lost in the crash: // refund the stake (approved refund-on-recovery policy). - chips, credits := rec.Chips, rec.Credit - p.Credits += credits - if chips > 0 { - g.giveChips(p, chips) + p.Credits += rec.Credit + if rec.Chips > 0 { + g.giveChips(p, rec.Chips) } if err := g.AccountStore.SaveCharacter(p); err != nil { fmt.Fprintf(os.Stderr, "economy recovery: refund save %s: %v\n", p.Name, err) @@ -263,27 +424,17 @@ func (g *Game) recoverEconomy() { } g.Economy.settle(rec) compensated++ - case rec.Kind == "wager": - // Balance moved past the recorded checkpoint: cannot compensate - // reliably; surface for operator inspection. - fmt.Fprintf(os.Stderr, "economy recovery: ambiguous wager %s for %s (credits %d, want before %d or after %d)\n", - rec.ID, rec.Player, p.Credits, rec.CreditsBefore, rec.CreditsAfter) - g.Economy.void(rec) - case rec.Kind == "payout" && p.Credits == rec.CreditsBefore: - // The payout was lost in the crash: the player is owed it. - if err := g.payCreditsDurable(p, rec.Credit); err != nil { - fmt.Fprintf(os.Stderr, "economy recovery: payout save %s: %v\n", p.Name, err) + default: + if resolvedByPayout { + g.Economy.settle(rec) continue } - g.Economy.settle(rec) - compensated++ - case rec.Kind == "payout" && p.Credits == rec.CreditsAfter: - g.Economy.settle(rec) - case rec.Kind == "payout": - fmt.Fprintf(os.Stderr, "economy recovery: ambiguous payout %s for %s (credits %d, want before %d or after %d)\n", + fmt.Fprintf(os.Stderr, "economy recovery: ambiguous wager %s for %s (credits %d, want before %d or after %d) — kept open for operator inspection\n", rec.ID, rec.Player, p.Credits, rec.CreditsBefore, rec.CreditsAfter) - g.Economy.settle(rec) } } g.Economy.compact() + // Rebuild the outstanding-stake index from whatever open wager records + // remain (normally none; ambiguous ones stay refundable/resolvable). + g.Economy.reloadStakes() } diff --git a/internal/game/economy_recovery_regression_test.go b/internal/game/economy_recovery_regression_test.go new file mode 100644 index 0000000..e085e67 --- /dev/null +++ b/internal/game/economy_recovery_regression_test.go @@ -0,0 +1,169 @@ +package game + +// Economy recovery regressions for the outstanding-stake ledger: a crash +// with a round in flight refunds the stake, chip stakes recover in chips, +// a failed payout is re-applied or preserved for operator inspection, and +// a resolved round (payout evidence) is never refunded twice. + +import ( + "testing" + + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/player" +) + +// TestRecoveryRefundsInFlightMachineWager: a wager debited durably with the +// round still in flight (no graceful shutdown, no resolution) is refunded by +// startup recovery — the documented refund-on-crash policy. +func TestRecoveryRefundsInFlightMachineWager(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice", "pw", false) + p := h.seedCharacter("alice", "alice") + h.g.RunOnOwner(func() { + p.Credits = 100 + h.g.Casino.JoinMachine(1, casino.MachineConfig{ID: "slots", Game: casino.GameSlots}, "alice") + h.g.Casino.SetMachineBet(1, "slots", "alice", 10, casinoWallet{g: h.g, p: p}) + }) + h.barrier() + + // A fresh Game over the saved files simulates losing all live round + // state in a crash. The stake record is open, so recovery refunds it. + restarted := harnessFromDir(t, h.dir) + loaded, err := restarted.g.AccountStore.LoadCharacter("alice") + if err != nil { + t.Fatal(err) + } + if loaded.Credits != 100 { + t.Fatalf("crash with an active 10-credit spin: recovered %d credits, want 100", loaded.Credits) + } + if open := restarted.g.Economy.openStakes("alice"); len(open) != 0 { + t.Fatalf("refunded stake left open: %+v", open) + } +} + +// TestRecoveryRefundsChipOnlyWager: a chip-only debit must be recognized by +// comparing chip balances, not credits alone (the credit checkpoints are +// identical before and after a chip-only wager). +func TestRecoveryRefundsChipOnlyWager(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: 20}) + if err := h.g.AccountStore.SaveCharacter(p); err != nil { + t.Error(err) + } + // Simulate the crash window: the chip debit is durable in the + // character file but the stake record never resolved. + p.RemoveItem("chips", 10) + if err := h.g.AccountStore.SaveCharacter(p); err != nil { + t.Error(err) + } + if _, err := h.g.Economy.start("wager", p, 10, 0, 100, 100, 20, 10); err != nil { + t.Error(err) + } + h.g.recoverEconomy() + }) + loaded, err := h.g.AccountStore.LoadCharacter("alice") + if err != nil { + t.Fatal(err) + } + if got := countChips(loaded); got != 20 { + t.Fatalf("chip stake after recovery = %d, want 20 (refunded in chips)", got) + } +} + +// TestRecoveryReappliesLostPayoutWithEvidence: a won hand whose payout +// failed to save leaves an open payout record AND an open stake record. +// Recovery re-applies the payout (which includes the stake return) and +// settles the stake from that evidence — exactly once, no double refund. +func TestRecoveryReappliesLostPayoutWithEvidence(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice", "pw", false) + p := h.seedCharacter("alice", "alice") + h.g.RunOnOwner(func() { + p.Credits = 90 // post-debit balance: the 10-credit stake was charged + if err := h.g.AccountStore.SaveCharacter(p); err != nil { + t.Error(err) + } + // Open stake for the lost round (before=100, after=90). + if _, err := h.g.Economy.start("wager", p, 0, 10, 100, 90, 0, 0); err != nil { + t.Error(err) + } + // Open payout for the won hand (2x bet = 20, before=90, after=110). + if _, err := h.g.Economy.start("payout", p, 0, 20, 90, 110, 0, 0); err != nil { + t.Error(err) + } + h.g.recoverEconomy() + }) + loaded, err := h.g.AccountStore.LoadCharacter("alice") + if err != nil { + t.Fatal(err) + } + if loaded.Credits != 110 { + t.Fatalf("balance after recovery = %d, want 110 (payout re-applied once, no double stake refund)", loaded.Credits) + } + if open := h.g.Economy.loadOpen(); len(open) != 0 { + t.Fatalf("records left open after recovery: %+v", open) + } +} + +// TestRecoveryRefundsSequentialStakes: a bet plus a double (two open stake +// records, newest processed first) both refund after a crash. +func TestRecoveryRefundsSequentialStakes(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice", "pw", false) + p := h.seedCharacter("alice", "alice") + h.g.RunOnOwner(func() { + p.Credits = 80 // bet 10 then doubled: 100 -> 90 -> 80 + if err := h.g.AccountStore.SaveCharacter(p); err != nil { + t.Error(err) + } + if _, err := h.g.Economy.start("wager", p, 0, 10, 100, 90, 0, 0); err != nil { + t.Error(err) + } + if _, err := h.g.Economy.start("wager", p, 0, 10, 90, 80, 0, 0); err != nil { + t.Error(err) + } + h.g.recoverEconomy() + }) + loaded, err := h.g.AccountStore.LoadCharacter("alice") + if err != nil { + t.Fatal(err) + } + if loaded.Credits != 100 { + t.Fatalf("balance after recovery = %d, want 100 (both stakes refunded)", loaded.Credits) + } +} + +// TestRecoveryPreservesAmbiguousPayout: a payout whose checkpoints match +// neither balance state stays open and is surfaced for operator inspection — +// a liability is never silently dropped. +func TestRecoveryPreservesAmbiguousPayout(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice", "pw", false) + p := h.seedCharacter("alice", "alice") + h.g.RunOnOwner(func() { + // A payout owed at balance 100 failed to save; the player then + // spent 5 credits elsewhere before the crash. + p.Credits = 95 + if err := h.g.AccountStore.SaveCharacter(p); err != nil { + t.Error(err) + } + if _, err := h.g.Economy.start("payout", p, 0, 20, 100, 120, 0, 0); err != nil { + t.Error(err) + } + h.g.recoverEconomy() + }) + loaded, err := h.g.AccountStore.LoadCharacter("alice") + if err != nil { + t.Fatal(err) + } + if loaded.Credits != 95 { + t.Fatalf("ambiguous recovery mutated the balance: %d, want 95", loaded.Credits) + } + if open := h.g.Economy.loadOpen(); len(open) != 1 { + t.Fatalf("ambiguous payout must stay open for operator inspection: %+v", open) + } +} diff --git a/internal/game/economy_shutdown_test.go b/internal/game/economy_shutdown_test.go index bd6c253..a3e6adb 100644 --- a/internal/game/economy_shutdown_test.go +++ b/internal/game/economy_shutdown_test.go @@ -101,9 +101,19 @@ func TestEconomyWagerDebitsDurallyAndSettles(t *testing.T) { if p.Credits != 90 { t.Fatalf("credits after wager = %d, want 90", p.Credits) } - // The journal must show the record settled (debit is durable). - if open := h.g.Economy.loadOpen(); len(open) != 0 { - t.Fatalf("open records remain after a successful wager: %v", open) + // The debit is durable, and the record stays OPEN: the stake is + // outstanding until the round resolves (recovery refunds open stakes + // after a crash). + if open := h.g.Economy.openStakes("alice"); len(open) != 1 || open[0].Credit != 10 { + t.Fatalf("outstanding stake after a successful wager: %+v", open) + } + // Resolution (round resolved or refunded) closes it. + h.g.RunOnOwner(func() { + w.ResolveStakes() + }) + h.barrier() + if open := h.g.Economy.openStakes("alice"); len(open) != 0 { + t.Fatalf("open stakes remain after resolution: %v", open) } } diff --git a/internal/game/owner.go b/internal/game/owner.go index df87653..2fb4777 100644 --- a/internal/game/owner.go +++ b/internal/game/owner.go @@ -23,6 +23,7 @@ import "sync" // order relative to subscriber registration IDs. type owner struct { mu sync.Mutex + cond *sync.Cond closed bool tasks chan func() done chan struct{} @@ -35,6 +36,7 @@ func newOwner() *owner { tasks: make(chan func(), ownerTaskQueue), done: make(chan struct{}), } + o.cond = sync.NewCond(&o.mu) go o.loop() return o } @@ -43,12 +45,15 @@ func (o *owner) loop() { defer close(o.done) for task := range o.tasks { task() + // A queue slot freed up; wake one blocked submitter waiting for + // room (see enqueue). + o.cond.Signal() } } -// submit enqueues a task; it returns false once the owner has stopped. -// The closed flag is guarded by the mutex so a submit racing with stop -// cannot send on a closed channel. +// submit enqueues without blocking. It returns false when the owner has +// stopped or the queue is full; callers that need guaranteed delivery use +// enqueue instead. func (o *owner) submit(fn func()) bool { o.mu.Lock() defer o.mu.Unlock() @@ -63,11 +68,31 @@ func (o *owner) submit(fn func()) bool { } } +// enqueue submits with backpressure: it blocks while the queue is full and +// returns false only when the owner has stopped. A full queue must NEVER +// bypass the owner: running a task on the caller's goroutine while the +// owner keeps executing other tasks breaks the single-owner invariant that +// serializes all live state mutation. +func (o *owner) enqueue(fn func()) bool { + o.mu.Lock() + defer o.mu.Unlock() + for !o.closed { + select { + case o.tasks <- fn: + return true + default: + o.cond.Wait() + } + } + return false +} + func (o *owner) stop() { o.mu.Lock() if !o.closed { o.closed = true close(o.tasks) + o.cond.Broadcast() } o.mu.Unlock() <-o.done @@ -83,13 +108,14 @@ func (g *Game) run(fn func()) { return } task := make(chan struct{}) - if !o.submit(func() { + if !o.enqueue(func() { fn() close(task) }) { // The owner loop has stopped (graceful shutdown in progress). // Admissions are already closed, so only stragglers reach this: - // execute inline rather than block forever. + // execute inline rather than block forever. A full queue never + // takes this path: enqueue applies backpressure instead. fn() return } diff --git a/internal/game/owner_regression_test.go b/internal/game/owner_regression_test.go new file mode 100644 index 0000000..1ccd25f --- /dev/null +++ b/internal/game/owner_regression_test.go @@ -0,0 +1,68 @@ +package game + +// Single-owner serialization regressions: a full task queue applies +// backpressure instead of bypassing the owner, and the owner-stop path +// keeps its documented inline fallback for shutdown stragglers only. + +import ( + "sync" + "testing" + "time" +) + +// TestFullOwnerQueueAppliesBackpressure: when the owner's task queue is +// full and the owner is blocked inside another task, run() must NOT execute +// the new task inline on the caller's goroutine — that would mutate live +// state concurrently with the owner. It blocks until the owner drains. +func TestFullOwnerQueueAppliesBackpressure(t *testing.T) { + g := &Game{owner: newOwner()} + release := make(chan struct{}) + started := make(chan struct{}) + g.owner.submit(func() { + close(started) + <-release + }) + <-started + var once sync.Once + t.Cleanup(func() { + once.Do(func() { close(release) }) + g.stopOwner() + }) + + for i := 0; i < ownerTaskQueue; i++ { + if !g.owner.submit(func() {}) { + t.Fatal("setup: could not fill the owner queue") + } + } + + ran := make(chan struct{}) + go g.run(func() { close(ran) }) + select { + case <-ran: + t.Fatal("task executed inline while the owner was still running other tasks") + case <-time.After(100 * time.Millisecond): + } + + // Release the owner: the queued work drains and the blocked task runs. + once.Do(func() { close(release) }) + select { + case <-ran: + case <-time.After(5 * time.Second): + t.Fatal("task never ran after the owner drained its queue") + } +} + +// TestStoppedOwnerRunsStragglersInline: after the owner stops (graceful +// shutdown), straggler submissions execute inline rather than blocking +// forever — the documented admissions-closed behavior. +func TestStoppedOwnerRunsStragglersInline(t *testing.T) { + g := &Game{owner: newOwner()} + g.stopOwner() + ran := make(chan struct{}) + go g.run(func() { close(ran) }) + select { + case <-ran: + case <-time.After(5 * time.Second): + t.Fatal("straggler task blocked forever after the owner stopped") + } +} |
