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.go242
1 files changed, 189 insertions, 53 deletions
diff --git a/internal/game/economy.go b/internal/game/economy.go
index afdcdd8..e917fa6 100644
--- a/internal/game/economy.go
+++ b/internal/game/economy.go
@@ -42,19 +42,33 @@ import (
"fmt"
"os"
"path/filepath"
+ "strings"
"time"
"thehouseoficarus/internal/player"
)
type economyRecord struct {
- ID string `json:"id"`
- Player string `json:"player"`
- Kind string `json:"kind"` // "wager" or "payout"
- Chips int `json:"chips"`
- Credit int `json:"credits"`
- Status string `json:"status"` // "open", "settled", "void"
- TS time.Time `json:"ts"`
+ ID string `json:"id"`
+ Player string `json:"player"`
+ // PlayerID is the character's stable UUID (player.Player.ID), assigned
+ // at creation and backfilled at first startup. Records resolve their
+ // subject by ID at recovery so a character RENAME can never strand an
+ // owed payout or outstanding stake; Player (the name) is kept for
+ // operator inspection and for legacy records written before IDs.
+ PlayerID string `json:"player_id,omitempty"`
+ Kind string `json:"kind"` // "wager" or "payout"
+ Chips int `json:"chips"`
+ Credit int `json:"credits"`
+ Status string `json:"status"` // "open", "settled", "void"
+ TS time.Time `json:"ts"`
+ // Refund marks a payout record as a SHUTDOWN/EVICTION REFUND (the
+ // stake being returned), not a game settlement. Recovery must not
+ // treat the chip part of a refund as paid in credits: a refund's chip
+ // stake returns in kind, so a crash mid-refund still owes the chips.
+ // Settlement payouts (a win, push, or surrender return) instead prove
+ // the round resolved, closing the stake without any chip return.
+ Refund bool `json:"refund,omitempty"`
// Balance checkpoints used to decide recovery compensation.
CreditsBefore int `json:"credits_before"`
@@ -65,8 +79,9 @@ 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
+ // stakes indexes outstanding wager records per player identity key
+ // (economyKey: the character's stable ID when known, else its 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
}
@@ -107,12 +122,37 @@ func (j *economyJournal) append(rec economyRecord) error {
return nil
}
+// appendRetry appends one durable line, retrying a bounded number of
+// times: a transient write failure must not permanently lose a marker
+// that money correctness depends on.
+func (j *economyJournal) appendRetry(rec economyRecord, attempts int) error {
+ var err error
+ for i := 0; i < max(attempts, 1); i++ {
+ if err = j.append(rec); err == nil {
+ return nil
+ }
+ }
+ return err
+}
+
// start opens a durable record for the in-flight transaction.
func (j *economyJournal) start(kind string, p *player.Player, chips, credits, creditsBefore, creditsAfter, chipsBefore, chipsAfter int) (*economyRecord, error) {
+ return j.startKind(kind, false, p, chips, credits, creditsBefore, creditsAfter, chipsBefore, chipsAfter)
+}
+
+// startRefund opens a refund payout record (stake returned at shutdown or
+// eviction; see economyRecord.Refund).
+func (j *economyJournal) startRefund(p *player.Player, credits, creditsBefore, creditsAfter int) (*economyRecord, error) {
+ return j.startKind("payout", true, p, 0, credits, creditsBefore, creditsAfter, 0, 0)
+}
+
+func (j *economyJournal) startKind(kind string, refund bool, p *player.Player, chips, credits, creditsBefore, creditsAfter, chipsBefore, chipsAfter int) (*economyRecord, error) {
rec := &economyRecord{
ID: newEconomyRecordID(),
Player: p.Name,
+ PlayerID: p.ID,
Kind: kind,
+ Refund: refund,
Chips: chips,
Credit: credits,
Status: "open",
@@ -143,22 +183,55 @@ func (j *economyJournal) void(rec *economyRecord) {
// 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)
+ j.stakes[rec.key()] = append(j.stakes[rec.key()], rec)
}
-// openStakes returns the outstanding stake records for a player.
+// openStakes returns the outstanding stake records for a player identity
+// key (see economyKey).
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.
+// A marker whose append cannot be made durable leaves that stake OPEN
+// (never dropped from the index): recovery then settles it from later
+// payout evidence, or — if the round was a loss with no settlement
+// payout — refunds it under the documented refund-on-crash policy. A
+// stake is only forgotten once its marker is durable.
func (j *economyJournal) ResolveStakes(player string) {
+ remaining := j.stakes[player][:0]
for _, rec := range j.stakes[player] {
rec.Status = "settled"
- _ = j.append(*rec)
+ if err := j.appendRetry(*rec, 3); err != nil {
+ // The marker is not durable: the stake stays open so recovery
+ // (or a later attempt) still resolves it.
+ rec.Status = "open"
+ remaining = append(remaining, rec)
+ }
+ }
+ if len(remaining) == 0 {
+ delete(j.stakes, player)
+ } else {
+ j.stakes[player] = remaining
}
- delete(j.stakes, player)
+}
+
+// economyKey is the identity a player's journal records are indexed by:
+// the stable character ID when known, else the (renameable) name.
+func economyKey(p *player.Player) string {
+ if p.ID != "" {
+ return p.ID
+ }
+ return p.Name
+}
+
+// economyRecordKey mirrors economyKey for a raw record.
+func (rec *economyRecord) key() string {
+ if rec.PlayerID != "" {
+ return rec.PlayerID
+ }
+ return rec.Player
}
// reloadStakes rebuilds the outstanding-stake index from the journal file:
@@ -289,6 +362,8 @@ func (g *Game) giveChips(p *player.Player, qty int) bool {
}
// payCreditsDurable applies a checked credit increase and persists it.
+// A failed save reverts the in-memory mutation: recovery must keep
+// comparing later records against the DURABLE balance.
func (g *Game) payCreditsDurable(p *player.Player, amount int) error {
if amount < 0 {
return fmt.Errorf("negative payout %d", amount)
@@ -297,8 +372,13 @@ func (g *Game) payCreditsDurable(p *player.Player, amount int) error {
if err != nil {
return err
}
+ before := p.Credits
p.Credits = sum
- return g.AccountStore.SaveCharacter(p)
+ if err := g.AccountStore.SaveCharacter(p); err != nil {
+ p.Credits = before
+ return err
+ }
+ return nil
}
// addCreditsChecked is the game-side checked addition against the economy
@@ -337,37 +417,75 @@ func (g *Game) recoverEconomy() {
return
}
folded := g.Economy.loadFolded()
- players := make(map[string]*player.Player)
- loadPlayer := func(name string) *player.Player {
- if p, ok := players[name]; ok {
- return p
+ // 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
+ // up front so records resolve by stable ID without filename guessing.
+ playersByID := make(map[string]*player.Player)
+ playersByName := make(map[string]*player.Player)
+ if names, err := g.AccountStore.ListCharacterNames(); err == nil {
+ for _, name := range names {
+ if p, err := g.AccountStore.LoadCharacter(name); err == nil {
+ if p.ID != "" {
+ playersByID[p.ID] = p
+ }
+ playersByName[strings.ToLower(p.Name)] = 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", name, err)
- return nil
+ }
+ loadPlayer := func(rec *economyRecord) *player.Player {
+ if rec.PlayerID != "" {
+ if p, ok := playersByID[rec.PlayerID]; ok {
+ return p
+ }
+ }
+ if rec.Player != "" {
+ if p, ok := playersByName[strings.ToLower(rec.Player)]; ok {
+ return p
+ }
}
- players[name] = p
- return p
+ if rec.PlayerID != "" {
+ // A character file added since the index was built: load every
+ // file once and match by stable ID.
+ if names, err := g.AccountStore.ListCharacterNames(); err == nil {
+ for _, name := range names {
+ 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
+ }
+ }
+ }
+ }
+ // 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 (id %q): character not found\n", rec.Player, rec.PlayerID)
+ return nil
}
// laterPayoutEvidence reports whether any non-void payout record was
// journaled for the player after seq — evidence the round resolved and
- // its settlement was recorded — together with the total credits those
- // records promise. A payout covering the stake's full value returned
- // the stake inside the payout (win, push, or refund); a smaller total
- // (e.g. the credit side of a crashed mixed refund) leaves the chip
- // side of the stake outstanding.
- laterPayoutEvidence := func(name string, seq int) (resolved bool, credits int) {
+ // its settlement was recorded.
+ //
+ // settlement separately reports whether a SETTLEMENT payout (not a
+ // refund) was journaled later: a settlement — even a partial one like
+ // a surrendered hand or the first hand of a multi-hand win — proves
+ // the round resolved, so the stake must NOT be refunded and its chip
+ // part must NOT return in kind on top of the settlement (the chips
+ // were lost at settlement, by the rules of the game). A REFUND payout
+ // instead marks a refund in progress: its credit part is re-applied by
+ // phase 1 and the chip part (if the debit was durable) still comes
+ // back in chips.
+ laterPayoutEvidence := func(name string, seq int) (resolved bool, settlement bool) {
for _, fr := range folded {
if fr.rec.Kind == "payout" && fr.rec.Player == name && fr.rec.Status != "void" && fr.seq > seq {
resolved = true
- credits += fr.rec.Credit
+ if !fr.rec.Refund {
+ settlement = true
+ }
}
}
- return resolved, credits
+ return resolved, settlement
}
compensated := 0
@@ -399,7 +517,7 @@ func (g *Game) recoverEconomy() {
if rec.Kind != "payout" || rec.Status != "open" || settled[rec.ID] {
continue
}
- p := loadPlayer(rec.Player)
+ p := loadPlayer(rec)
if p == nil {
continue
}
@@ -413,7 +531,12 @@ func (g *Game) recoverEconomy() {
// failure (e.g. the credit cap) leaves the unpaid remainder open
// for operator inspection — a liability is never erased.
paid := 0
+ capBlocked := false
for m := 0; m < len(group); m++ {
+ if _, err := addCreditsChecked(p.Credits, rec.Credit); err != nil {
+ capBlocked = true
+ break
+ }
if err := g.payCreditsDurable(p, rec.Credit); err != nil {
fmt.Fprintf(os.Stderr, "economy recovery: payout save %s: %v\n", p.Name, err)
break
@@ -427,17 +550,26 @@ func (g *Game) recoverEconomy() {
settled[other.ID] = true
continue
}
- // An unpaid sibling (the save failed, or the credit cap
- // blocked re-application) stays owed. Re-anchor its
- // checkpoints to the current durable balance so a later
- // recovery re-applies it against a balance it can actually
- // be paid from — without this, the balance a PAID sibling
- // moved reads as this record's own durability and the
- // liability is silently settled instead of paid.
- other.CreditsBefore = p.Credits
- other.CreditsAfter = p.Credits + other.Credit
+ if capBlocked {
+ // The credit cap blocked re-application. Re-anchor the
+ // record's checkpoints to the current durable balance so
+ // a later recovery re-applies it against a balance it can
+ // actually be paid from — without this, the balance a PAID
+ // sibling moved reads as this record's own durability and
+ // the liability is silently settled instead of paid.
+ other.CreditsBefore = p.Credits
+ other.CreditsAfter = p.Credits + other.Credit
+ settled[other.ID] = true // handled for the rest of this run
+ _ = g.Economy.append(*other)
+ continue
+ }
+ // A TRANSIENT save failure blocked re-application and the
+ // balance still has room below the cap: keep the record open
+ // with its ORIGINAL checkpoints. Re-anchoring here would make
+ // the record unreadable against the unchanged durable balance
+ // (before-checkpoint mismatch) and permanently ambiguous; the
+ // original checkpoints let a later recovery retry cleanly.
settled[other.ID] = true // handled for the rest of this run
- _ = g.Economy.append(*other)
}
case p.Credits == rec.CreditsAfter && len(group) == 1:
g.Economy.settle(rec)
@@ -457,8 +589,8 @@ func (g *Game) recoverEconomy() {
}
for k := len(openWagers) - 1; k >= 0; k-- {
rec := &folded[openWagers[k]].rec
- resolvedByPayout, laterPayoutCredits := laterPayoutEvidence(rec.Player, folded[openWagers[k]].seq)
- p := loadPlayer(rec.Player)
+ resolvedByPayout, resolvedBySettlement := laterPayoutEvidence(rec.Player, folded[openWagers[k]].seq)
+ p := loadPlayer(rec)
if p == nil {
continue
}
@@ -467,12 +599,16 @@ func (g *Game) recoverEconomy() {
creditsNow, chipsNow := p.Credits, countChips(p)
// Chips are recovered independently of the credit checkpoints. Any
// state where the chip debit is durable but the chips have not come
- // back (post-debit chip balance) still owes the chips — unless the
- // resolving payout already returned the stake's full value in
- // credits (a win, push, or refund includes the stake): returning
- // the chips in kind on top would double-refund the round.
+ // back (post-debit chip balance) still owes the chips — unless a
+ // SETTLEMENT payout was journaled later: a settlement proves the
+ // round resolved, so the chips were lost at settlement by the rules
+ // of the game, and returning them in kind on top would double-pay
+ // the round (e.g. a surrendered hand paying half the stake must not
+ // ALSO return the full chip stake). A REFUND payout instead marks a
+ // refund in progress: its credit part was re-applied by phase 1 and
+ // the chip part still returns in chips.
chipsOwed := rec.Chips > 0 && chipsNow == rec.ChipsAfter &&
- (!resolvedByPayout || laterPayoutCredits < rec.Chips+rec.Credit)
+ !resolvedBySettlement
if chipsOwed && !g.giveChips(p, rec.Chips) {
// No inventory room for the chip return: keep the record open
// for operator inspection rather than silently dropping the