aboutsummaryrefslogtreecommitdiff
path: root/internal/game/money.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game/money.go')
-rw-r--r--internal/game/money.go182
1 files changed, 182 insertions, 0 deletions
diff --git a/internal/game/money.go b/internal/game/money.go
new file mode 100644
index 0000000..35355ca
--- /dev/null
+++ b/internal/game/money.go
@@ -0,0 +1,182 @@
+package game
+
+// Single-file money: the character file is the ONLY durable money record.
+// Every casino money mutation is one atomic SaveCharacter that carries both
+// the balance and the Player.PendingWagers ledger. There is no separate
+// economy journal and no two-phase recovery: startup simply refunds any stake
+// still listed as pending, because a listed stake means the process died
+// before the round resolved. Two-phase journal recovery was deliberately
+// removed as out of scope for a game this size; see
+// building_guide/concurrency.md ("Durable money") before reintroducing it.
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "fmt"
+ "os"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/player"
+)
+
+// newPendingWagerID returns an opaque wallet handle for a committed stake. A
+// game keeps it to name the stake when its settlement covers it (a table
+// hand) or consumes it (a loss).
+func newPendingWagerID() string {
+ var b [8]byte
+ if _, err := rand.Read(b[:]); err != nil {
+ panic(fmt.Sprintf("pending wager id: %v", err))
+ }
+ return hex.EncodeToString(b[:])
+}
+
+// removePendingWager drops the stake with id from the player's ledger and
+// reports whether it was present.
+func removePendingWager(p *player.Player, id string) bool {
+ if id == "" {
+ return false
+ }
+ kept := p.PendingWagers[:0]
+ removed := false
+ for _, pending := range p.PendingWagers {
+ if pending.ID == id {
+ removed = true
+ continue
+ }
+ kept = append(kept, pending)
+ }
+ p.PendingWagers = kept
+ return removed
+}
+
+// takePendingWagers removes and returns the pending stakes whose IDs are
+// listed, leaving the rest. It is used by a settlement that returns a
+// specific set of stakes (a table hand).
+func takePendingWagers(p *player.Player, ids []string) []player.PendingWager {
+ want := make(map[string]bool, len(ids))
+ for _, id := range ids {
+ want[id] = true
+ }
+ var removed []player.PendingWager
+ kept := p.PendingWagers[:0]
+ for _, pending := range p.PendingWagers {
+ if want[pending.ID] {
+ removed = append(removed, pending)
+ continue
+ }
+ kept = append(kept, pending)
+ }
+ p.PendingWagers = kept
+ return removed
+}
+
+// giveChips re-credits chips to a player's inventory (used by refund paths).
+// It stacks onto an existing chips slot when one exists. It reports false
+// when the chips do NOT fit (no existing stack and no free slot): the caller
+// must then treat the chips as undelivered — they are never silently dropped.
+func (g *Game) giveChips(p *player.Player, qty int) bool {
+ for _, slot := range p.Inventory {
+ if slot != nil && slot.ItemID == "chips" {
+ slot.Quantity += qty
+ return true
+ }
+ }
+ slot := p.FirstFreeSlot()
+ if slot < 0 {
+ return false
+ }
+ p.SetInvSlot(slot, &player.InventorySlot{ItemID: "chips", Quantity: qty})
+ return true
+}
+
+// grantCredits adds credits from ordinary gameplay (shop sales, credit
+// pickups, activity payouts). The economy cap is enforced by clamping: the
+// cap is far above any reachable balance, so hitting it means a data bug —
+// clamp (never wrap) and log loudly for the operator.
+func (g *Game) grantCredits(p *player.Player, amount int) {
+ if amount <= 0 {
+ return
+ }
+ sum, err := addCreditsChecked(p.Credits, amount)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "credit grant for %s clamped at the economy cap: %v\n", p.Name, err)
+ p.Credits = casino.MaxCredits
+ return
+ }
+ p.Credits = sum
+}
+
+// addCreditsChecked is the game-side checked addition against the shared
+// economy cap (casino.MaxCredits, mirrored by the casino wallet: a second,
+// independently drifting cap would let the two sides of the money path
+// disagree about what a balance may hold).
+func addCreditsChecked(a, b int) (int, error) {
+ if b > 0 && a > casino.MaxCredits-b {
+ return casino.MaxCredits, fmt.Errorf("credit overflow: %d + %d exceeds %d", a, b, casino.MaxCredits)
+ }
+ if b < 0 {
+ return 0, fmt.Errorf("negative credit add %d", b)
+ }
+ return a + b, nil
+}
+
+// recoverPendingWagers refunds every stake left in flight by an ungraceful
+// stop. The character file is the only durable money record, so a pending
+// wager on disk means the process died before the round resolved: the player
+// is made whole by returning the stake (chips in kind, credits as credits).
+// Rounds that resolved are already gone from PendingWagers, removed by the
+// same save that settled them.
+//
+// This is intentionally the entire recovery story. Two-phase journal recovery
+// is out of scope for this game (see building_guide/concurrency.md).
+func (g *Game) recoverPendingWagers() {
+ names, err := g.AccountStore.ListCharacterNames()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "pending wager recovery: list characters: %v\n", err)
+ return
+ }
+ for _, name := range names {
+ p, err := g.AccountStore.LoadCharacter(name)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "pending wager recovery: load %s: %v\n", name, err)
+ continue
+ }
+ if len(p.PendingWagers) == 0 {
+ continue
+ }
+ before := append([]player.PendingWager(nil), p.PendingWagers...)
+ credits, chips := 0, 0
+ for _, pending := range before {
+ credits += pending.Credits
+ chips += pending.Chips
+ }
+ creditsBefore := p.Credits
+ p.PendingWagers = nil
+ if chips > 0 && !g.giveChips(p, chips) {
+ // No inventory room to return the chip stakes: leave the ledger
+ // intact so a later startup (once the player has room) retries.
+ p.PendingWagers = before
+ fmt.Fprintf(os.Stderr, "pending wager recovery: %s: no inventory room for %d chips — stake retained for a later attempt\n", name, chips)
+ continue
+ }
+ sum, capErr := addCreditsChecked(creditsBefore, credits)
+ if capErr != nil {
+ if chips > 0 {
+ p.RemoveItem("chips", chips)
+ }
+ p.PendingWagers = before
+ fmt.Fprintf(os.Stderr, "pending wager recovery: %s: refund blocked at the credit cap: %v — stake retained for a later attempt\n", name, capErr)
+ continue
+ }
+ p.Credits = sum
+ if err := g.AccountStore.SaveCharacter(p); err != nil {
+ p.Credits = creditsBefore
+ if chips > 0 {
+ p.RemoveItem("chips", chips)
+ }
+ p.PendingWagers = before
+ fmt.Fprintf(os.Stderr, "pending wager recovery: refund save %s: %v\n", name, err)
+ continue
+ }
+ }
+}