game_casino_bonus.gno
10.05 Kb · 301 lines
1package game33
2
3import (
4 "strconv"
5 "time"
6
7 "chain"
8
9 "gno.land/p/nt/avl/v0"
10)
11
12// ── Slots bonus features, all settled by the realm ──────────────────────────
13//
14// Free spins: 3/4/5 scatters on a paid spin grant 10/12/15 free spins at that
15// spin's bet. The grant is a realm record (one open grant per player). The
16// triggering spin keeps its 500× reservation for the whole feature, minus what
17// it already paid, so every free spin can always be paid in full. A free spin is
18// its own two-sided round (PlaceCasinoFreeSpin takes no stake); its wins are
19// doubled, it pays from that reservation, scatters don't retrigger and vaults
20// don't start Hold & Spin. The grant never expires for the player.
21//
22// Hold & Spin: 3+ vaults on a paid spin. Resolved inside that spin's settlement
23// from the same seed stream (game_slots.gno). Coin tiles pay 1/2/3/5× the bet,
24// MINI 20×, MINOR 75× (all under the 500× cap). The first MAJOR / GRAND tile
25// pays that jackpot pool (extra ones pay as MINOR); a full board (20 tiles) also
26// awards GRAND.
27//
28// MAJOR / GRAND jackpots: real pools per coin, fed with 1.0% / 0.5% of every
29// paid slots stake (fractions carried exactly). A hit pays
30// pool × min(1, bet / refBet); the rest stays. A pool can never pay more than
31// it holds. After a hit the pool is topped back up to its reset seed from the
32// house pool, only while the free house pool is ≥ 20× that top-up. The admin
33// can add its own coins to a jackpot but nobody can withdraw one.
34
35var (
36 casinoGrants avl.Tree // addr → *freeSpinGrant
37 casinoJackpot avl.Tree // coin:tier → pool (string)
38 casinoJpDust avl.Tree // coin:tier → contribution remainder in bet×bps units (string)
39 casinoJpSeed avl.Tree // coin:tier → reset seed (string)
40 casinoJpRef avl.Tree // coin → reference bet (string)
41)
42
43const (
44 freeSpinMult = uint64(2)
45 jpMajorBps = uint64(100) // 1.0% of each paid slots stake
46 jpGrandBps = uint64(50) // 0.5%
47 jpDefaultRefBet = uint64(100)
48 jpResetFreeMultiple = uint64(20)
49 hsMiniMult = uint64(20)
50 hsMinorMult = uint64(75)
51)
52
53type freeSpinGrant struct {
54 addr, coin string
55 bet uint64
56 left uint64
57 total uint64
58 capLeft uint64 // still reserved in casinoReserved for this feature
59 won uint64
60 inRound string
61 fromRound string
62 grantedAt int64
63}
64
65func getGrant(addr string) *freeSpinGrant {
66 if !casinoGrants.Has(addr) {
67 return nil
68 }
69 return casinoGrants.Get(addr).(*freeSpinGrant)
70}
71
72func jpKey(coin, tier string) string { return coin + ":" + tier }
73
74func jpRefBet(coin string) uint64 {
75 if v := casGet(&casinoJpRef, coin); v > 0 {
76 return v
77 }
78 return jpDefaultRefBet
79}
80
81// casinoJackpotContribute moves 1.0% / 0.5% of a paid slots stake from the house
82// pool into the MAJOR / GRAND pools (remainders carried, nothing rounded away).
83func casinoJackpotContribute(coin string, amt uint64) {
84 for _, t := range [2]struct {
85 tier string
86 bps uint64
87 }{{"major", jpMajorBps}, {"grand", jpGrandBps}} {
88 k := jpKey(coin, t.tier)
89 dust := casAdd(casGet(&casinoJpDust, k), casMul(amt, t.bps))
90 units := dust / 10000
91 if units == 0 {
92 casSet(&casinoJpDust, k, dust)
93 continue
94 }
95 pool := casGet(&casinoPool, coin)
96 if pool < casAdd(casGet(&casinoReserved, coin), units) {
97 casSet(&casinoJpDust, k, dust) // house can't spare it right now: keep it owed
98 continue
99 }
100 casSet(&casinoJpDust, k, dust%10000)
101 casSet(&casinoPool, coin, pool-units)
102 casSet(&casinoJackpot, k, casAdd(casGet(&casinoJackpot, k), units))
103 day, snap, paid, recv := casinoDayState(coin)
104 setCasinoDay(coin, day, snap, casAdd(paid, units), recv)
105 }
106}
107
108// casinoJackpotPay pays a jackpot tier to addr: pool × min(1, bet/refBet).
109func casinoJackpotPay(addr, coin, tier string, bet uint64) uint64 {
110 k := jpKey(coin, tier)
111 pool := casGet(&casinoJackpot, k)
112 ref := jpRefBet(coin)
113 pay := pool
114 if bet < ref {
115 pay = casMulDiv(pool, bet, ref)
116 }
117 casSet(&casinoJackpot, k, pool-pay)
118 casCredit(addr, coin, pay)
119 chain.Emit("CasinoJackpotWon", "player", addr, "coin", coin, "tier", tier, "amount", casU(pay))
120 // Top back up to the reset seed from the house pool, only if the house can easily afford it.
121 seed := casGet(&casinoJpSeed, k)
122 left := pool - pay
123 if seed > left {
124 need := seed - left
125 house := casGet(&casinoPool, coin)
126 reserved := casGet(&casinoReserved, coin)
127 if house > reserved && house-reserved >= casMul(need, jpResetFreeMultiple) {
128 casSet(&casinoPool, coin, house-need)
129 casSet(&casinoJackpot, k, seed)
130 day, snap, paid, recv := casinoDayState(coin)
131 setCasinoDay(coin, day, snap, casAdd(paid, need), recv)
132 }
133 }
134 return pay
135}
136
137// slotsHSPayout turns Hold & Spin tiles into bet multiples (under the cap) and
138// the jackpot tiers won (paid from their pools).
139func slotsHSPayout(tiles *[slotsCells]uint16, full bool) (uint64, bool, bool) {
140 mult := uint64(0)
141 major, grand := false, false
142 for i := 0; i < slotsCells; i++ {
143 v := tiles[i]
144 switch {
145 case v == 0:
146 case v < 100:
147 mult += uint64(v)
148 case v == hsMini:
149 mult += hsMiniMult
150 case v == hsMinor:
151 mult += hsMinorMult
152 case v == hsMajor && !major:
153 major = true
154 case v == hsGrand && !grand:
155 grand = true
156 default: // a second MAJOR / GRAND in the same bonus pays as MINOR
157 mult += hsMinorMult
158 }
159 }
160 if full && !grand {
161 grand = true
162 }
163 return mult, major, grand
164}
165
166// PlaceCasinoFreeSpin uses one free spin of the caller's grant in a committed
167// round (no stake). One free spin in flight at a time.
168func PlaceCasinoFreeSpin(cur realm, roundId string, clientHash string) {
169 requireNotPaused()
170 caller := cur.Previous().Address().String()
171 if isBlacklisted(caller) {
172 panic("address banned")
173 }
174 r := mustCasinoRound(roundId)
175 if r.player != caller {
176 panic("this round belongs to another player")
177 }
178 if r.status != "committed" {
179 panic("betting is closed for this round")
180 }
181 if time.Now().Unix()-r.committedAt >= casinoBetWindowSec {
182 panic("round expired")
183 }
184 g := getGrant(caller)
185 if g == nil || g.left == 0 {
186 panic("no free spins left")
187 }
188 if g.inRound != "" {
189 panic("a free spin is already in progress (round " + g.inRound + ")")
190 }
191 if !casIsHex64(clientHash) {
192 panic("client hash must be sha256 hex")
193 }
194 g.inRound = roundId
195 casinoBets.Set(roundId+":"+caller, &casinoBet{addr: caller, game: "slots", coin: g.coin, choice: "free", clientHash: clientHash, amount: g.bet, freeSpin: true})
196 r.status = "bet"
197 r.betAt = time.Now().Unix()
198 r.bets = 1
199}
200
201// settleFreeSpin pays a free spin's win out of its grant's reservation.
202func settleFreeSpin(b *casinoBet, win uint64) uint64 {
203 g := getGrant(b.addr)
204 if g == nil {
205 panic("free spin grant missing")
206 }
207 if win > g.capLeft {
208 win = g.capLeft
209 b.capped = true
210 }
211 if win > 0 {
212 casSet(&casinoReserved, b.coin, casSub(casGet(&casinoReserved, b.coin), win))
213 casSet(&casinoPool, b.coin, casSub(casGet(&casinoPool, b.coin), win))
214 day, snap, paid, recv := casinoDayState(b.coin)
215 setCasinoDay(b.coin, day, snap, casAdd(paid, win), recv)
216 casCredit(b.addr, b.coin, win)
217 }
218 g.capLeft -= win
219 g.won += win
220 g.left--
221 g.inRound = ""
222 if g.left == 0 {
223 casSet(&casinoReserved, b.coin, casSub(casGet(&casinoReserved, b.coin), g.capLeft))
224 casinoGrants.Remove(b.addr)
225 chain.Emit("CasinoFreeSpinsDone", "player", b.addr, "coin", b.coin, "won", casU(g.won))
226 }
227 return win
228}
229
230// FreeSpinGrant reports a player's open free spins (null when none).
231func FreeSpinGrant(addr string) string {
232 g := getGrant(addr)
233 if g == nil {
234 return "null"
235 }
236 return `{"coin":"` + g.coin + `","bet":` + casU(g.bet) + `,"left":` + casU(g.left) + `,"total":` + casU(g.total) +
237 `,"mult":` + casU(freeSpinMult) + `,"capLeft":` + casU(g.capLeft) + `,"won":` + casU(g.won) +
238 `,"inRound":"` + g.inRound + `","fromRound":"` + g.fromRound + `"}`
239}
240
241// CasinoJackpots reports the real MAJOR / GRAND pools and how they pay.
242func CasinoJackpots(coinId string) string {
243 return `{"coin":"` + coinId + `","major":"` + casU(casGet(&casinoJackpot, jpKey(coinId, "major"))) +
244 `","grand":"` + casU(casGet(&casinoJackpot, jpKey(coinId, "grand"))) +
245 `","majorSeed":"` + casU(casGet(&casinoJpSeed, jpKey(coinId, "major"))) +
246 `","grandSeed":"` + casU(casGet(&casinoJpSeed, jpKey(coinId, "grand"))) +
247 `","refBet":"` + casU(jpRefBet(coinId)) + `","miniMult":` + casU(hsMiniMult) + `,"minorMult":` + casU(hsMinorMult) +
248 `,"majorBps":` + casU(jpMajorBps) + `,"grandBps":` + casU(jpGrandBps) + `}`
249}
250
251func validJpTier(tier string) bool { return tier == "major" || tier == "grand" }
252
253// SeedCasinoJackpot adds the ADMIN'S OWN coins to a jackpot pool (never withdrawable).
254func SeedCasinoJackpot(cur realm, coinId, tier, amount string) {
255 if cur.Previous().Address() != admin {
256 panic("unauthorized: admin only")
257 }
258 if _, ok := coinRarity[coinId]; !ok {
259 panic("unknown coin id: " + coinId)
260 }
261 if !validJpTier(tier) {
262 panic("tier must be major or grand")
263 }
264 amt, err := strconv.ParseUint(amount, 10, 64)
265 if err != nil || amt == 0 || amt > casinoMaxAmount {
266 panic("invalid amount")
267 }
268 casDebit(admin.String(), coinId, amt)
269 k := jpKey(coinId, tier)
270 casSet(&casinoJackpot, k, casAdd(casGet(&casinoJackpot, k), amt))
271 casStat(coinId, "", "seeded", amt)
272 chain.Emit("CasinoJackpotSeeded", "coin", coinId, "tier", tier, "amount", amount)
273}
274
275// SetCasinoJackpotSeed sets the level a jackpot is topped back up to after a hit.
276func SetCasinoJackpotSeed(cur realm, coinId, tier, amount string) {
277 if cur.Previous().Address() != admin {
278 panic("unauthorized: admin only")
279 }
280 if !validJpTier(tier) {
281 panic("tier must be major or grand")
282 }
283 amt, err := strconv.ParseUint(amount, 10, 64)
284 if err != nil || amt > casinoMaxAmount {
285 panic("invalid amount")
286 }
287 casSet(&casinoJpSeed, jpKey(coinId, tier), amt)
288}
289
290// SetCasinoJackpotRefBet sets the bet that wins a full jackpot (smaller bets win
291// the same share of the pool as their share of this bet).
292func SetCasinoJackpotRefBet(cur realm, coinId, amount string) {
293 if cur.Previous().Address() != admin {
294 panic("unauthorized: admin only")
295 }
296 amt, err := strconv.ParseUint(amount, 10, 64)
297 if err != nil || amt == 0 || amt > casinoMaxAmount {
298 panic("invalid amount")
299 }
300 casSet(&casinoJpRef, coinId, amt)
301}