diff options
Diffstat (limited to 'internal/game/money.go')
| -rw-r--r-- | internal/game/money.go | 182 |
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 + } + } +} |
