aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game')
-rw-r--r--internal/game/casino.go60
-rw-r--r--internal/game/casino_forfeit_regression_test.go61
-rw-r--r--internal/game/core_login_account.go14
-rw-r--r--internal/game/economy.go241
-rw-r--r--internal/game/economy_recovery_regression_test.go169
-rw-r--r--internal/game/economy_shutdown_test.go16
-rw-r--r--internal/game/owner.go36
-rw-r--r--internal/game/owner_regression_test.go68
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")
+ }
+}