aboutsummaryrefslogtreecommitdiff
path: root/internal/game/economy.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game/economy.go')
-rw-r--r--internal/game/economy.go181
1 files changed, 176 insertions, 5 deletions
diff --git a/internal/game/economy.go b/internal/game/economy.go
index 3d48605..0d5f08f 100644
--- a/internal/game/economy.go
+++ b/internal/game/economy.go
@@ -13,7 +13,14 @@ package game
// 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.
+// startup is owed money and is re-applied. Durability is proven by
+// the character file itself, not by balance checkpoints: the same
+// save that includes the payout in the balance records the record's
+// ID in the character's open_payouts evidence list, and the entry is
+// dropped once the settled marker is durable. Checkpoints alone are
+// ambiguous — an unrelated credit change (shop sale, bank deposit)
+// can coincidentally land on the record's CreditsAfter balance, and
+// settling on that coincidence silently erases an owed payout.
//
// A crash at any point therefore leaves either:
// - an open record with the character file at its pre-transaction state
@@ -70,6 +77,13 @@ type economyRecord struct {
// the round resolved, closing the stake without any chip return.
Refund bool `json:"refund,omitempty"`
+ // Tracked marks a payout written under the character-file evidence
+ // scheme: the record's ID is recorded in the character's open_payouts
+ // list by the same save that includes its amount in the balance, and
+ // recovery uses that membership — not balance checkpoints — to decide
+ // whether the payout became durable.
+ Tracked bool `json:"tracked,omitempty"`
+
// Balance checkpoints used to decide recovery compensation.
CreditsBefore int `json:"credits_before"`
CreditsAfter int `json:"credits_after"`
@@ -84,12 +98,17 @@ type economyJournal struct {
// 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
+ // openPayouts indexes payout records that are still open (journaled but
+ // not provably durable). It drives evidence pruning: a character-file
+ // ID absent from this set is settled or void and must not be trusted as
+ // durability evidence.
+ openPayouts map[string]bool
}
func newEconomyJournal(dataDir string) *economyJournal {
dir := filepath.Join(dataDir, "economy")
_ = os.MkdirAll(dir, 0700)
- return &economyJournal{path: filepath.Join(dir, "journal.jsonl"), stakes: make(map[string][]*economyRecord)}
+ return &economyJournal{path: filepath.Join(dir, "journal.jsonl"), stakes: make(map[string][]*economyRecord), openPayouts: make(map[string]bool)}
}
func newEconomyRecordID() string {
@@ -153,6 +172,7 @@ func (j *economyJournal) startKind(kind string, refund bool, p *player.Player, c
PlayerID: p.ID,
Kind: kind,
Refund: refund,
+ Tracked: kind == "payout",
Chips: chips,
Credit: credits,
Status: "open",
@@ -164,13 +184,33 @@ func (j *economyJournal) startKind(kind string, refund bool, p *player.Player, c
if err := j.append(*rec); err != nil {
return nil, err
}
+ if rec.Tracked {
+ j.openPayouts[rec.ID] = true
+ }
return rec, nil
}
// settle marks a record settled (the balance mutation is durable).
+// Fire-and-forget: callers whose recovery semantics depend on the marker
+// being durable use settleDurable instead.
func (j *economyJournal) settle(rec *economyRecord) {
rec.Status = "settled"
_ = j.append(*rec)
+ delete(j.openPayouts, rec.ID)
+}
+
+// settleDurable marks a record settled and persists the marker with retries,
+// reporting whether the marker became durable. A false return leaves the
+// record open in the journal: callers keeping file-side evidence for this
+// record must NOT drop that evidence until this returns true.
+func (j *economyJournal) settleDurable(rec *economyRecord) bool {
+ rec.Status = "settled"
+ if err := j.appendRetry(*rec, 3); err != nil {
+ rec.Status = "open"
+ return false
+ }
+ delete(j.openPayouts, rec.ID)
+ return true
}
// void marks a record void (the mutation was reverted in memory and never
@@ -178,6 +218,7 @@ func (j *economyJournal) settle(rec *economyRecord) {
func (j *economyJournal) void(rec *economyRecord) {
rec.Status = "void"
_ = j.append(*rec)
+ delete(j.openPayouts, rec.ID)
}
// registerStake tracks an outstanding wager record for its player. Wager
@@ -257,11 +298,15 @@ func payoutIdentifies(a, b economyRecord) bool {
// recovery compaction.
func (j *economyJournal) reloadStakes() {
j.stakes = make(map[string][]*economyRecord)
+ j.openPayouts = make(map[string]bool)
for _, fr := range j.loadFolded() {
if fr.rec.Kind == "wager" && fr.rec.Status == "open" {
rec := fr.rec
j.registerStake(&rec)
}
+ if fr.rec.Kind == "payout" && fr.rec.Status == "open" {
+ j.openPayouts[fr.rec.ID] = true
+ }
}
}
@@ -399,6 +444,30 @@ func (g *Game) payCreditsDurable(p *player.Player, amount int) error {
return nil
}
+// applyOpenPayout re-applies an open TRACKED payout record during recovery:
+// checked credit increase plus a character save that records the record ID
+// in the file's open_payouts evidence list. A failed save reverts the
+// in-memory mutation (credits and evidence) so the record stays open and a
+// later recovery attempt retries cleanly.
+func (g *Game) applyOpenPayout(p *player.Player, rec *economyRecord) error {
+ if rec.Credit < 0 {
+ return fmt.Errorf("negative payout %d", rec.Credit)
+ }
+ sum, err := addCreditsChecked(p.Credits, rec.Credit)
+ if err != nil {
+ return err
+ }
+ before := p.Credits
+ p.Credits = sum
+ p.OpenPayouts = append(p.OpenPayouts, rec.ID)
+ if err := g.AccountStore.SaveCharacter(p); err != nil {
+ p.Credits = before
+ removeOpenPayout(p, rec.ID)
+ return err
+ }
+ return nil
+}
+
// addCreditsChecked is the game-side checked addition against the economy
// cap (mirrors casino.MaxCredits; kept local to avoid a dependency cycle).
func addCreditsChecked(a, b int) (int, error) {
@@ -412,6 +481,54 @@ func addCreditsChecked(a, b int) (int, error) {
return a + b, nil
}
+// openPayoutRecorded reports whether the character's durable evidence list
+// claims rec's amount is included in the balance.
+func openPayoutRecorded(p *player.Player, id string) bool {
+ for _, existing := range p.OpenPayouts {
+ if existing == id {
+ return true
+ }
+ }
+ return false
+}
+
+// removeOpenPayout drops id from the in-memory evidence list; the on-disk
+// copy is corrected by the next character save.
+func removeOpenPayout(p *player.Player, id string) {
+ kept := p.OpenPayouts[:0]
+ for _, existing := range p.OpenPayouts {
+ if existing != id {
+ kept = append(kept, existing)
+ }
+ }
+ p.OpenPayouts = kept
+}
+
+// pruneOpenPayouts drops evidence IDs that no longer correspond to an open
+// payout record (settled or void). It reports whether anything changed, so
+// callers only pay for a save when the file would actually differ.
+func pruneOpenPayouts(j *economyJournal, p *player.Player) bool {
+ if j == nil || len(p.OpenPayouts) == 0 {
+ return false
+ }
+ if j.openPayouts == nil {
+ j.openPayouts = make(map[string]bool)
+ }
+ kept := p.OpenPayouts[:0]
+ changed := false
+ for _, id := range p.OpenPayouts {
+ if j.openPayouts[id] {
+ kept = append(kept, id)
+ } else {
+ changed = true
+ }
+ }
+ if changed {
+ p.OpenPayouts = kept
+ }
+ return changed
+}
+
// recoverEconomy compensates open records from the last crash. It is called
// once from New, before any session connects.
//
@@ -439,6 +556,19 @@ func (g *Game) recoverEconomy() {
return
}
folded := g.Economy.loadFolded()
+ if g.Economy.openPayouts == nil {
+ g.Economy.openPayouts = make(map[string]bool)
+ } else {
+ for id := range g.Economy.openPayouts {
+ delete(g.Economy.openPayouts, id)
+ }
+ }
+ for i := range folded {
+ rec := &folded[i].rec
+ if rec.Kind == "payout" && rec.Status == "open" {
+ g.Economy.openPayouts[rec.ID] = true
+ }
+ }
// Player records resolve by the STABLE character ID (a rename must
// never strand an owed payout); the name lookup remains for legacy
// records written before IDs existed. Every character file is loaded
@@ -455,6 +585,18 @@ func (g *Game) recoverEconomy() {
}
}
}
+ // finalizeLoaded prunes stale open-payout evidence the first time a
+ // character file is touched: IDs the journal shows as settled or void
+ // cannot prove anything and are dropped (with a durable save) so the
+ // list stays small and meaningful.
+ finalizeLoaded := func(p *player.Player) *player.Player {
+ if pruneOpenPayouts(g.Economy, p) {
+ if err := g.AccountStore.SaveCharacter(p); err != nil {
+ fmt.Fprintf(os.Stderr, "economy recovery: evidence prune save %s: %v\n", p.Name, err)
+ }
+ }
+ return p
+ }
loadPlayer := func(rec *economyRecord) *player.Player {
if rec.PlayerID != "" {
// A record carrying a stable ID resolves to THAT identity only.
@@ -463,7 +605,7 @@ func (g *Game) recoverEconomy() {
// character's payout and the original liability would be
// settled as paid. See TestRecoveryDoesNotPayReusedCharacterName.
if p, ok := playersByID[rec.PlayerID]; ok {
- return p
+ return finalizeLoaded(p)
}
// A character file added since the index was built: load every
// file once and match by stable ID.
@@ -472,7 +614,7 @@ func (g *Game) recoverEconomy() {
if p, err := g.AccountStore.LoadCharacter(name); err == nil && p.ID == rec.PlayerID {
playersByID[p.ID] = p
playersByName[strings.ToLower(p.Name)] = p
- return p
+ return finalizeLoaded(p)
}
}
}
@@ -486,7 +628,7 @@ func (g *Game) recoverEconomy() {
// name is the only identity available.
if rec.Player != "" {
if p, ok := playersByName[strings.ToLower(rec.Player)]; ok {
- return p
+ return finalizeLoaded(p)
}
}
fmt.Fprintf(os.Stderr, "economy recovery: load %s: character not found\n", rec.Player)
@@ -623,6 +765,35 @@ func (g *Game) recoverEconomy() {
if p == nil {
continue
}
+ if rec.Tracked {
+ // Tracked records carry durable evidence in the character file
+ // itself: the same save that includes the amount in the balance
+ // records the record ID in open_payouts. Membership decides.
+ // Balance checkpoints are NEVER consulted for tracked records:
+ // unrelated credit changes (shop sales, bank deposits, other
+ // games) can coincidentally land on CreditsAfter, and settling
+ // on that coincidence silently erases an owed payout.
+ if openPayoutRecorded(p, rec.ID) {
+ // The save landed; the amount is already in the balance.
+ g.Economy.settle(rec)
+ settled[rec.ID] = true
+ continue
+ }
+ if err := g.applyOpenPayout(p, rec); err != nil {
+ // Cap-blocked or transiently failed: the record stays open
+ // (no evidence entry was added) and a later recovery
+ // retries. A liability is never silently dropped.
+ fmt.Fprintf(os.Stderr, "economy recovery: tracked payout %s for %s not re-applied (%v) — kept open for operator inspection\n",
+ rec.ID, rec.Player, err)
+ } else {
+ if g.Economy.settleDurable(rec) {
+ removeOpenPayout(p, rec.ID)
+ }
+ compensated++
+ }
+ settled[rec.ID] = true
+ continue
+ }
sig := payoutSig{rec.key(), rec.CreditsBefore, rec.CreditsAfter, rec.Credit}
group := siblings[sig]
switch {