aboutsummaryrefslogtreecommitdiff
path: root/internal/game/economy.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-15 19:55:21 -0400
committerhistoria <[not public]>2026-09-15 19:55:21 -0400
commit6f3c43aca62a7d33a19f14f6ad7c16891952673a (patch)
tree0b0665ef624ef442df87ec7131129aaecc3420bd /internal/game/economy.go
parentcd95de60364aba8f5bdf8ec86f7234a9903eacc8 (diff)
downloadthehouseoficarus-6f3c43aca62a7d33a19f14f6ad7c16891952673a.tar.gz
fix: outstanding-stake ledger, owner backpressure, editor state preservation
Diffstat (limited to 'internal/game/economy.go')
-rw-r--r--internal/game/economy.go241
1 files changed, 196 insertions, 45 deletions
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()
}