aboutsummaryrefslogtreecommitdiff
path: root/internal/game/money.go
blob: 6317df88e1b86f896c3eab309a8d21474af89567 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
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
}

// 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
		}
	}
}