game_casino_rounds.gno
12.56 Kb · 362 lines
1package game31
2
3import (
4 "crypto/sha256"
5 "encoding/hex"
6 "strconv"
7 "strings"
8 "time"
9
10 "chain"
11)
12
13// ── rounds ──────────────────────────────────────────────────────────────────
14
15func getCasinoRound(id string) *casinoRound {
16 if !casinoRounds.Has(id) {
17 return nil
18 }
19 r, ok := casinoRounds.Get(id).(*casinoRound)
20 if !ok {
21 return nil
22 }
23 return r
24}
25
26func mustCasinoRound(id string) *casinoRound {
27 r := getCasinoRound(id)
28 if r == nil {
29 panic("round not found")
30 }
31 return r
32}
33
34func casinoRoundBet(r *casinoRound, roundId string) *casinoBet {
35 key := roundId + ":" + r.player
36 if !casinoBets.Has(key) {
37 return nil
38 }
39 return casinoBets.Get(key).(*casinoBet)
40}
41
42// CommitCasinoSeed opens a round for one player with sha256(s). Operator only;
43// a round id can be used once.
44func CommitCasinoSeed(cur realm, roundId string, player string, seedHash string) {
45 if !isOperator(cur) {
46 panic("unauthorized: casino operator only")
47 }
48 if !casSafe(roundId, 64) {
49 panic("invalid round id")
50 }
51 if casinoRounds.Has(roundId) {
52 panic("round already exists")
53 }
54 if !address(player).IsValid() {
55 panic("invalid player address")
56 }
57 seedHash = strings.ToLower(seedHash)
58 if !casIsHex64(seedHash) {
59 panic("seedHash must be sha256 hex")
60 }
61 casinoRounds.Set(roundId, &casinoRound{player: player, seedHash: seedHash, status: "committed", committedAt: time.Now().Unix()})
62}
63
64// PlaceCasinoBet escrows the player's bet with sha256(c): only the round's
65// player, only before s is revealed, and only if the pool can pay its largest win.
66func PlaceCasinoBet(cur realm, roundId string, game string, coinId string, betAmount string, choice string, clientHash string) {
67 requireNotPaused()
68 caller := cur.Previous().Address().String()
69 if isBlacklisted(caller) {
70 panic("address banned")
71 }
72 r := mustCasinoRound(roundId)
73 if r.player != caller {
74 panic("this round belongs to another player")
75 }
76 if r.status != "committed" {
77 panic("betting is closed for this round")
78 }
79 if time.Now().Unix()-r.committedAt >= casinoBetWindowSec {
80 panic("round expired")
81 }
82 if _, ok := coinRarity[coinId]; !ok {
83 panic("unknown coin id: " + coinId)
84 }
85 if !validCasinoChoice(game, choice) {
86 panic("invalid game or choice: " + game + "/" + choice)
87 }
88 clientHash = strings.ToLower(clientHash)
89 if !casIsHex64(clientHash) {
90 panic("client hash must be sha256 hex")
91 }
92 amt, err := strconv.ParseUint(betAmount, 10, 64)
93 if err != nil || amt == 0 || amt > casinoMaxAmount {
94 panic("invalid bet amount")
95 }
96 if game == "slots" && getGrant(caller) != nil {
97 panic("finish your free spins first")
98 }
99 // Check everything before any write, then debit and reserve the largest win.
100 casinoCheckBet(coinId, game, amt)
101 exp := casinoExposure(game, amt)
102 casDebit(caller, coinId, amt)
103 casinoReserve(coinId, exp)
104 casinoBets.Set(roundId+":"+caller, &casinoBet{addr: caller, game: game, coin: coinId, choice: choice, clientHash: clientHash, amount: amt, exposure: exp})
105 r.status = "bet"
106 r.betAt = time.Now().Unix()
107 r.bets = 1
108}
109
110// RevealCasinoSeed publishes s (it must hash to the commit). It settles nothing:
111// the bet settles when the player's secret is revealed.
112func RevealCasinoSeed(cur realm, roundId string, serverSeed string) {
113 if !isOperator(cur) {
114 panic("unauthorized: casino operator only")
115 }
116 r := mustCasinoRound(roundId)
117 if r.status != "committed" && r.status != "bet" {
118 panic("round already revealed or closed")
119 }
120 h := sha256.Sum256([]byte(serverSeed))
121 if hex.EncodeToString(h[:]) != r.seedHash {
122 panic("server seed does not match the committed hash")
123 }
124 r.serverSeed = serverSeed
125 if r.status == "committed" {
126 r.status = "closed" // no bet was placed: the round just ends
127 return
128 }
129 r.status = "seeded"
130 r.seededAt = time.Now().Unix()
131}
132
133// RevealCasinoSecret publishes the player's secret c (anyone may relay it: it
134// must hash to the bet's hash) and settles the bet on chain. Works while paused.
135func RevealCasinoSecret(cur realm, roundId string, clientSecret string) {
136 r := mustCasinoRound(roundId)
137 if r.status != "seeded" {
138 panic("round is not waiting for the client secret")
139 }
140 b := casinoRoundBet(r, roundId)
141 if b == nil || b.settled {
142 panic("no open bet in this round")
143 }
144 if !casSafe(clientSecret, 64) {
145 panic("invalid client secret")
146 }
147 if sha256Hex(clientSecret) != b.clientHash {
148 panic("client secret does not match the bet's hash")
149 }
150 b.clientSecret = clientSecret
151 casinoResolve(roundId, r, b)
152 r.status = "settled"
153}
154
155// casinoResolve rolls the bet from s and c, pays it, and records the result.
156func casinoResolve(roundId string, r *casinoRound, b *casinoBet) {
157 payout := uint64(0)
158 switch b.game {
159 case "slots":
160 s := newSlotsStream(r.serverSeed, b.clientSecret, roundId)
161 g := slotsSpinGrid(s)
162 mult, lines := slotsLineMult(&g)
163 sc := slotsCount(&g, symScatter)
164 win := casMul(b.amount/slotsBetStep, mult)
165 if sc >= 3 {
166 win = casAdd(win, casMul(b.amount, slotsScatterPay[sc]))
167 }
168 b.grid = slotsGridString(&g)
169 b.lineMult = mult
170 b.lines = lines
171 b.scatters = sc
172 if b.freeSpin {
173 // ×2, no retrigger, no Hold & Spin; paid from the grant's reservation.
174 payout = settleFreeSpin(b, casMul(win, freeSpinMult))
175 b.payout = payout
176 b.won = payout > 0
177 b.outcome = "lose"
178 if payout > 0 {
179 b.outcome = "win"
180 }
181 b.settled = true
182 casRecord(b.coin, "slots", 0, payout)
183 casStat(b.coin, "slots", "freeSpins", 1)
184 chain.Emit("CasinoBetSettled", "round", roundId, "player", b.addr, "game", "slots", "coin", b.coin,
185 "amount", "0", "freeSpin", "true", "outcome", b.outcome, "won", strconv.FormatBool(b.won), "payout", casU(payout))
186 return
187 }
188 major, grand := false, false
189 if slotsCount(&g, symVault) >= 3 {
190 tiles, played, full := slotsHoldAndSpin(s, &g)
191 hm, mj, gr := slotsHSPayout(&tiles, full)
192 b.hsTiles = hsTilesString(&tiles)
193 b.hsRespins = played
194 b.hsMult = hm
195 major, grand = mj, gr
196 win = casAdd(win, casMul(b.amount, hm))
197 }
198 capWin := casMul(b.amount, slotsMaxWinMult)
199 if win > capWin {
200 win = capWin
201 b.capped = true
202 }
203 payout = casinoSettle(b.addr, b.coin, b.amount, b.exposure, win)
204 if sc >= 3 {
205 // The rest of this spin's 500× cap stays reserved for its free spins
206 // (reserved before anything else leaves the pool, so pool ≥ reserved holds).
207 n := slotsFreeSpins[sc]
208 capLeft := capWin - win
209 casinoReserve(b.coin, capLeft)
210 casinoGrants.Set(b.addr, &freeSpinGrant{addr: b.addr, coin: b.coin, bet: b.amount, left: n, total: n, capLeft: capLeft, fromRound: roundId, grantedAt: time.Now().Unix()})
211 b.fsGranted = n
212 chain.Emit("CasinoFreeSpinsGranted", "player", b.addr, "coin", b.coin, "spins", casU(n), "bet", casU(b.amount))
213 }
214 casinoJackpotContribute(b.coin, b.amount)
215 jw := uint64(0)
216 if grand {
217 jw = casAdd(jw, casinoJackpotPay(b.addr, b.coin, "grand", b.amount))
218 b.jackpots = "grand"
219 }
220 if major {
221 jw = casAdd(jw, casinoJackpotPay(b.addr, b.coin, "major", b.amount))
222 if b.jackpots != "" {
223 b.jackpots = "major+" + b.jackpots
224 } else {
225 b.jackpots = "major"
226 }
227 }
228 b.jackpotWin = jw
229 b.payout = casAdd(payout, jw)
230 b.won = b.payout > b.amount
231 b.outcome = "lose"
232 if b.payout > 0 {
233 b.outcome = "win"
234 }
235 b.settled = true
236 casRecord(b.coin, "slots", b.amount, b.payout)
237 chain.Emit("CasinoBetSettled", "round", roundId, "player", b.addr, "game", "slots", "coin", b.coin,
238 "amount", casU(b.amount), "outcome", b.outcome, "won", strconv.FormatBool(b.won), "payout", casU(b.payout),
239 "jackpots", b.jackpots, "freeSpins", casU(b.fsGranted))
240 return
241 case "rps":
242 b.roll = CasinoRoll(r.serverSeed, b.clientSecret, roundId, b.game)
243 res := CasinoOutcome("rps", b.roll)
244 b.outcome = rpsHouseMove(b.choice, res)
245 switch res {
246 case "win":
247 payout = casAdd(b.amount, b.amount)
248 b.won = true
249 case "push":
250 payout = b.amount
251 }
252 default:
253 b.roll = CasinoRoll(r.serverSeed, b.clientSecret, roundId, b.game)
254 b.outcome = CasinoOutcome(b.game, b.roll)
255 if b.outcome == b.choice {
256 payout = casAdd(b.amount, b.amount)
257 b.won = true
258 }
259 }
260 b.payout = casinoSettle(b.addr, b.coin, b.amount, b.exposure, payout)
261 b.settled = true
262 casRecord(b.coin, b.game, b.amount, b.payout)
263 chain.Emit("CasinoBetSettled", "round", roundId, "player", b.addr, "game", b.game, "coin", b.coin,
264 "amount", casU(b.amount), "roll", casU(b.roll), "outcome", b.outcome, "won", strconv.FormatBool(b.won), "payout", casU(b.payout))
265}
266
267// RefundCasinoBet returns the stake only if s was never revealed and 30 minutes
268// have passed since the bet. Never after s is revealed. Works while paused.
269func RefundCasinoBet(cur realm, roundId string) {
270 caller := cur.Previous().Address().String()
271 r := mustCasinoRound(roundId)
272 if r.status == "refunded" {
273 panic("bet already settled")
274 }
275 if r.status != "bet" {
276 panic("round already revealed: no refunds")
277 }
278 if r.player != caller {
279 panic("no bet found for this round")
280 }
281 if time.Now().Unix()-r.betAt < casinoSeedTimeoutSec {
282 panic("refund opens 30 minutes after the bet if the server seed is never revealed")
283 }
284 b := casinoRoundBet(r, roundId)
285 b.settled = true
286 b.refunded = true
287 if b.freeSpin {
288 // No stake was taken: the free spin goes back to the grant.
289 if g := getGrant(caller); g != nil && g.inRound == roundId {
290 g.inRound = ""
291 }
292 } else {
293 casSet(&casinoReserved, b.coin, casSub(casGet(&casinoReserved, b.coin), b.exposure))
294 casCredit(caller, b.coin, b.amount)
295 }
296 r.status = "refunded"
297 chain.Emit("CasinoBetRefunded", "round", roundId, "player", caller, "amount", casU(b.amount))
298}
299
300// ForfeitCasinoBet settles a bet as a loss when s was revealed but the player's
301// secret was not within 24 h (only the player could know the result). Anyone.
302func ForfeitCasinoBet(cur realm, roundId string) {
303 r := mustCasinoRound(roundId)
304 if r.status != "seeded" {
305 panic("round is not waiting for the client secret")
306 }
307 if time.Now().Unix()-r.seededAt < casinoSecretTimeoutSec {
308 panic("forfeit opens 24 hours after the server seed is revealed")
309 }
310 b := casinoRoundBet(r, roundId)
311 b.forfeited = true
312 b.outcome = "forfeit"
313 if b.freeSpin {
314 b.payout = settleFreeSpin(b, 0) // the free spin is used, it wins nothing
315 casRecord(b.coin, b.game, 0, 0)
316 } else {
317 b.payout = casinoSettle(b.addr, b.coin, b.amount, b.exposure, 0)
318 casRecord(b.coin, b.game, b.amount, 0)
319 }
320 b.settled = true
321 r.status = "settled"
322 chain.Emit("CasinoBetForfeited", "round", roundId, "player", b.addr, "amount", casU(b.amount))
323}
324
325// ── queries ─────────────────────────────────────────────────────────────────
326
327func CasinoRound(roundId string) string {
328 r := getCasinoRound(roundId)
329 if r == nil {
330 return "null"
331 }
332 return `{"roundId":"` + roundId + `","player":"` + r.player + `","seedHash":"` + r.seedHash + `","serverSeed":"` + r.serverSeed +
333 `","status":"` + r.status + `","committedAt":` + strconv.FormatInt(r.committedAt, 10) +
334 `,"betAt":` + strconv.FormatInt(r.betAt, 10) + `,"seededAt":` + strconv.FormatInt(r.seededAt, 10) +
335 `,"bets":` + strconv.Itoa(r.bets) + `}`
336}
337
338// CasinoBet returns one player's bet and its on-chain result.
339func CasinoBet(roundId string, addr string) string {
340 key := roundId + ":" + addr
341 if !casinoBets.Has(key) {
342 return "null"
343 }
344 b := casinoBets.Get(key).(*casinoBet)
345 return `{"addr":"` + b.addr + `","game":"` + b.game + `","coinId":"` + b.coin + `","amount":` + casU(b.amount) +
346 `,"exposure":` + casU(b.exposure) + `,"choice":"` + b.choice + `","clientHash":"` + b.clientHash +
347 `","clientSecret":"` + b.clientSecret + `","clientSeed":"` + b.clientSecret + `","settled":` + strconv.FormatBool(b.settled) +
348 `,"refunded":` + strconv.FormatBool(b.refunded) + `,"forfeited":` + strconv.FormatBool(b.forfeited) +
349 `,"won":` + strconv.FormatBool(b.won) + `,"roll":` + casU(b.roll) + `,"outcome":"` + b.outcome +
350 `","payout":` + casU(b.payout) + `,"grid":"` + b.grid + `","lineMult":` + casU(b.lineMult) +
351 `,"lines":` + strconv.Itoa(b.lines) + `,"scatters":` + strconv.Itoa(b.scatters) +
352 `,"freeSpin":` + strconv.FormatBool(b.freeSpin) + `,"capped":` + strconv.FormatBool(b.capped) +
353 `,"fsGranted":` + casU(b.fsGranted) + `,"hsTiles":"` + b.hsTiles + `","hsRespins":` + strconv.Itoa(b.hsRespins) +
354 `,"hsMult":` + casU(b.hsMult) + `,"jackpots":"` + b.jackpots + `","jackpotWin":` + casU(b.jackpotWin) + `}`
355}
356
357// JackpotPool reports the GRAND jackpot pool (the casino bankroll is CasinoPool).
358func JackpotPool(coinId string) string {
359 return `{"pool":"` + casU(casGet(&casinoJackpot, jpKey(coinId, "grand"))) + `","coinId":"` + coinId + `"}`
360}
361
362func CasinoHistory(_ string) string { return "[]" }