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. 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 } // countUniqueNonEmpty returns the number of distinct non-empty IDs in ids. // PayCredits/PayPoker use it to fail closed when a payout names a stake that // is not present (or names one twice): paying it anyway would double-pay with // no ledger entry left for startup recovery. func countUniqueNonEmpty(ids []string) int { seen := make(map[string]bool, len(ids)) for _, id := range ids { if id != "" { seen[id] = true } } return len(seen) } // 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. 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 } } }