game_casino_test.gno
29.81 Kb · 717 lines
1package game32
2
3import (
4 "strconv"
5 "strings"
6 "testing"
7
8 "gno.land/p/nt/avl/v0"
9 "gno.land/p/nt/testutils/v0"
10)
11
12func casAddr(name string) address { return testutils.TestAddress(name) }
13
14func casSetup() address {
15 adm := casAddr("cas-admin")
16 admin = adm
17 operator = address("")
18 return adm
19}
20
21func casResetPool(coin string, amt uint64) {
22 casSet(&casinoPool, coin, amt)
23 casinoReserved.Remove(coin)
24 casinoDay.Remove(coin)
25}
26
27func casSeedHash(seed string) string { return sha256Hex(seed) }
28
29func casAbort(t *testing.T, what string, abort any, want string) {
30 t.Helper()
31 if abort == nil {
32 t.Errorf("%s: expected abort, got success", what)
33 return
34 }
35 msg := ""
36 switch v := abort.(type) {
37 case string:
38 msg = v
39 case error:
40 msg = v.Error()
41 }
42 if want != "" && !strings.Contains(msg, want) {
43 t.Errorf("%s: aborted with %q, want %q", what, msg, want)
44 }
45}
46
47// casTotal = every holder of `coin` the casino can touch: the players' balances,
48// the pool and the stakes of open bets. Coins only move between them.
49func casTotal(coin string, addrs []address) uint64 {
50 tot := casGet(&casinoPool, coin) + casJp(coin)
51 for _, a := range addrs {
52 tot += casBalOf(a.String(), coin)
53 }
54 casinoBets.Iterate("", "", func(_ string, v any) bool {
55 b := v.(*casinoBet)
56 if b.coin == coin && !b.settled {
57 tot += b.amount
58 }
59 return false
60 })
61 return tot
62}
63
64// casJp = coins held in the coin's MAJOR + GRAND jackpot pools.
65func casJp(coin string) uint64 {
66 return casGet(&casinoJackpot, jpKey(coin, "major")) + casGet(&casinoJackpot, jpKey(coin, "grand"))
67}
68
69func casResetBonus() {
70 casinoStats = avl.Tree{}
71 casinoGrants = avl.Tree{}
72 casinoJackpot = avl.Tree{}
73 casinoJpDust = avl.Tree{}
74 casinoJpSeed = avl.Tree{}
75 casinoJpRef = avl.Tree{}
76}
77
78// casPlay runs one full round: commit (operator) → bet (player) → reveal s → reveal c.
79func casPlay(cur realm, t *testing.T, op, p address, round, game, choice string, amt uint64) *casinoBet {
80 t.Helper()
81 seed := "s-" + round
82 secret := "c-" + round
83 testing.SetRealm(testing.NewUserRealm(op))
84 CommitCasinoSeed(cross(cur), round, p.String(), casSeedHash(seed))
85 testing.SetRealm(testing.NewUserRealm(p))
86 PlaceCasinoBet(cross(cur), round, game, "grin", casU(amt), choice, sha256Hex(secret))
87 testing.SetRealm(testing.NewUserRealm(op))
88 RevealCasinoSeed(cross(cur), round, seed)
89 testing.SetRealm(testing.NewUserRealm(casAddr("cas-relayer")))
90 RevealCasinoSecret(cross(cur), round, secret)
91 return casinoBets.Get(round + ":" + p.String()).(*casinoBet)
92}
93
94func TestCasino_OutcomeRules(t *testing.T) {
95 for _, gc := range [][2]string{{"coin_flip", "heads"}, {"coin_flip", "tails"}, {"higher_lower", "higher"}, {"higher_lower", "lower"}} {
96 n := 0
97 for roll := uint64(0); roll < 100; roll++ {
98 if CasinoOutcome(gc[0], roll) == gc[1] {
99 n++
100 }
101 }
102 if n != 49 {
103 t.Errorf("%s/%s wins on %d of 100 rolls, want 49", gc[0], gc[1], n)
104 }
105 }
106 w, p, l := 0, 0, 0
107 for roll := uint64(0); roll < 100; roll++ {
108 switch CasinoOutcome("rps", roll) {
109 case "win":
110 w++
111 case "push":
112 p++
113 case "lose":
114 l++
115 }
116 }
117 // RTP = 0.33·2 + 0.32·1 = 98%
118 if w != 33 || p != 32 || l != 35 {
119 t.Errorf("rps table %d/%d/%d", w, p, l)
120 }
121 cases := [][3]string{{"rock", "win", "scissors"}, {"rock", "push", "rock"}, {"rock", "lose", "paper"},
122 {"paper", "win", "rock"}, {"paper", "lose", "scissors"}, {"scissors", "win", "paper"}, {"scissors", "lose", "rock"}}
123 for _, c := range cases {
124 if m := rpsHouseMove(c[0], c[1]); m != c[2] {
125 t.Errorf("rps house move %s/%s = %s, want %s", c[0], c[1], m, c[2])
126 }
127 }
128}
129
130func TestCasino_RolesAndPlayerBinding(cur realm, t *testing.T) {
131 adm := casSetup()
132 casResetPool("grin", 100000)
133 op := casAddr("cas-op")
134 p := casAddr("cas-bound")
135 other := casAddr("cas-other")
136 casSet(&balances, p.String()+":grin", 100)
137 casSet(&balances, other.String()+":grin", 100)
138 testing.SetRealm(testing.NewUserRealm(other))
139 casAbort(t, "outsider commit", revive(func() { CommitCasinoSeed(cross(cur), "cr-r1", p.String(), casSeedHash("s")) }), "operator only")
140 casAbort(t, "outsider sets operator", revive(func() { SetOperator(cross(cur), other.String()) }), "admin only")
141 casAbort(t, "outsider seeds pool", revive(func() { SeedCasinoPool(cross(cur), "grin", "10") }), "admin only")
142 testing.SetRealm(testing.NewUserRealm(adm))
143 SetOperator(cross(cur), op.String())
144 testing.SetRealm(testing.NewUserRealm(op))
145 CommitCasinoSeed(cross(cur), "cr-r1", p.String(), casSeedHash("s"))
146 casAbort(t, "recommit", revive(func() { CommitCasinoSeed(cross(cur), "cr-r1", p.String(), casSeedHash("x")) }), "already exists")
147 casAbort(t, "bad hash", revive(func() { CommitCasinoSeed(cross(cur), "cr-r2", p.String(), "nothex") }), "sha256")
148 casAbort(t, "bad player", revive(func() { CommitCasinoSeed(cross(cur), "cr-r2", "nope", casSeedHash("s")) }), "invalid player")
149 // Only the round's player can bet in it.
150 testing.SetRealm(testing.NewUserRealm(other))
151 casAbort(t, "other player bets", revive(func() {
152 PlaceCasinoBet(cross(cur), "cr-r1", "coin_flip", "grin", "10", "heads", sha256Hex("c"))
153 }), "another player")
154 testing.SetRealm(testing.NewUserRealm(p))
155 casAbort(t, "clear seed instead of hash", revive(func() {
156 PlaceCasinoBet(cross(cur), "cr-r1", "coin_flip", "grin", "10", "heads", "c")
157 }), "client hash")
158 PlaceCasinoBet(cross(cur), "cr-r1", "coin_flip", "grin", "10", "heads", sha256Hex("c"))
159 casAbort(t, "second bet", revive(func() {
160 PlaceCasinoBet(cross(cur), "cr-r1", "coin_flip", "grin", "10", "heads", sha256Hex("c"))
161 }), "closed")
162 // The secret can't be revealed before s, and only the operator reveals s.
163 casAbort(t, "secret before seed", revive(func() { RevealCasinoSecret(cross(cur), "cr-r1", "c") }), "not waiting")
164 casAbort(t, "player reveals seed", revive(func() { RevealCasinoSeed(cross(cur), "cr-r1", "s") }), "operator only")
165 testing.SetRealm(testing.NewUserRealm(op))
166 casAbort(t, "wrong seed", revive(func() { RevealCasinoSeed(cross(cur), "cr-r1", "nope") }), "does not match")
167 RevealCasinoSeed(cross(cur), "cr-r1", "s")
168 // Revealing s settles nothing: the operator didn't know c.
169 if b := casinoBets.Get("cr-r1:" + p.String()).(*casinoBet); b.settled || casBalOf(p.String(), "grin") != 90 {
170 t.Fatalf("seed reveal settled the bet")
171 }
172 casAbort(t, "double seed reveal", revive(func() { RevealCasinoSeed(cross(cur), "cr-r1", "s") }), "already revealed")
173 casAbort(t, "wrong secret", revive(func() { RevealCasinoSecret(cross(cur), "cr-r1", "d") }), "does not match")
174 testing.SetRealm(testing.NewUserRealm(p))
175 casAbort(t, "refund after seed reveal", revive(func() { RefundCasinoBet(cross(cur), "cr-r1") }), "no refunds")
176 RevealCasinoSecret(cross(cur), "cr-r1", "c")
177 if b := casinoBets.Get("cr-r1:" + p.String()).(*casinoBet); !b.settled {
178 t.Fatalf("not settled")
179 }
180 casAbort(t, "double secret", revive(func() { RevealCasinoSecret(cross(cur), "cr-r1", "c") }), "not waiting")
181 // A round with no bet just closes on reveal.
182 testing.SetRealm(testing.NewUserRealm(op))
183 CommitCasinoSeed(cross(cur), "cr-r3", p.String(), casSeedHash("s3"))
184 RevealCasinoSeed(cross(cur), "cr-r3", "s3")
185 if getCasinoRound("cr-r3").status != "closed" {
186 t.Errorf("empty round not closed")
187 }
188}
189
190func TestCasino_SettlesAndPaysFromPool(cur realm, t *testing.T) {
191 adm := casSetup()
192 casResetPool("grin", 0)
193 casSet(&balances, adm.String()+":grin", 100000)
194 testing.SetRealm(testing.NewUserRealm(adm))
195 casAbort(t, "seed more than owned", revive(func() { SeedCasinoPool(cross(cur), "grin", "100001") }), "insufficient")
196 SeedCasinoPool(cross(cur), "grin", "100000")
197 if casGet(&casinoPool, "grin") != 100000 || casBalOf(adm.String(), "grin") != 0 {
198 t.Fatalf("seed: pool=%d admin=%d", casGet(&casinoPool, "grin"), casBalOf(adm.String(), "grin"))
199 }
200 players := []address{}
201 for i := 0; i < 30; i++ {
202 players = append(players, casAddr("cas-p"+strconv.Itoa(i)))
203 casSet(&balances, players[i].String()+":grin", 1000)
204 }
205 games := [][2]string{{"coin_flip", "heads"}, {"coin_flip", "tails"}, {"higher_lower", "higher"}, {"higher_lower", "lower"}, {"rps", "rock"}, {"rps", "scissors"}}
206 total0 := casTotal("grin", players)
207 seen := map[string]int{}
208 for i, p := range players {
209 g := games[i%len(games)]
210 round := "cr-settle-" + strconv.Itoa(i)
211 pool0 := casGet(&casinoPool, "grin")
212 b := casPlay(cur, t, adm, p, round, g[0], g[1], 100)
213 roll := CasinoRoll("s-"+round, "c-"+round, round, g[0])
214 want := uint64(0)
215 res := CasinoOutcome(g[0], roll)
216 if g[0] == "rps" {
217 seen["rps-"+res]++
218 if res == "win" {
219 want = 200
220 } else if res == "push" {
221 want = 100
222 }
223 if b.outcome != rpsHouseMove(g[1], res) {
224 t.Errorf("rps house move %s for %s/%s", b.outcome, g[1], res)
225 }
226 } else if res == g[1] {
227 want = 200
228 }
229 if b.payout != want || b.roll != roll || casBalOf(p.String(), "grin") != 900+want {
230 t.Errorf("%s round %d: payout %d want %d (bal %d)", g[0], i, b.payout, want, casBalOf(p.String(), "grin"))
231 }
232 if casGet(&casinoPool, "grin") != pool0+100-want {
233 t.Errorf("pool %d → %d with payout %d", pool0, casGet(&casinoPool, "grin"), want)
234 }
235 if casGet(&casinoReserved, "grin") != 0 {
236 t.Errorf("reservation not released")
237 }
238 if casTotal("grin", players) != total0 {
239 t.Fatalf("conservation broken after round %d", i)
240 }
241 js := CasinoBet(round, p.String())
242 if !strings.Contains(js, `"settled":true`) || !strings.Contains(js, `"clientSecret":"c-`+round+`"`) {
243 t.Errorf("bet json %s", js)
244 }
245 }
246}
247
248func TestCasino_SlotsSettleOnChain(cur realm, t *testing.T) {
249 adm := casSetup()
250 casResetPool("grin", 10_000_000) // slots max bet = 0.004% = 400
251 p := casAddr("cas-slots")
252 casSet(&balances, p.String()+":grin", 1_000_000)
253 players := []address{p}
254 total0 := casTotal("grin", players)
255 wins, paid, staked := 0, uint64(0), uint64(0)
256 for i := 0; i < 60; i++ {
257 round := "cr-slot-" + strconv.Itoa(i)
258 pool0 := casGet(&casinoPool, "grin") + casJp("grin")
259 testing.SetRealm(testing.NewUserRealm(adm))
260 CommitCasinoSeed(cross(cur), round, p.String(), casSeedHash("s-"+round))
261 testing.SetRealm(testing.NewUserRealm(p))
262 PlaceCasinoBet(cross(cur), round, "slots", "grin", "100", "spin", sha256Hex("c-"+round))
263 if casGet(&casinoReserved, "grin") != 49900 {
264 t.Fatalf("slots reserve %d, want 499×100", casGet(&casinoReserved, "grin"))
265 }
266 testing.SetRealm(testing.NewUserRealm(adm))
267 RevealCasinoSeed(cross(cur), round, "s-"+round)
268 RevealCasinoSecret(cross(cur), round, "c-"+round)
269 b := casinoBets.Get(round + ":" + p.String()).(*casinoBet)
270 // Re-derive independently from the stream.
271 s := newSlotsStream("s-"+round, "c-"+round, round)
272 g := slotsSpinGrid(s)
273 mult, _ := slotsLineMult(&g)
274 sc := slotsCount(&g, symScatter)
275 want := 4 * mult
276 if sc >= 3 {
277 want += 100 * slotsScatterPay[sc]
278 }
279 if want > 50000 {
280 want = 50000
281 }
282 if b.grid != slotsGridString(&g) || b.payout != want || b.lineMult != mult || b.scatters != sc {
283 t.Errorf("slot %d: grid %s payout %d want %d", i, b.grid, b.payout, want)
284 }
285 if casGet(&casinoPool, "grin")+casJp("grin")+want != pool0+100 || casGet(&casinoReserved, "grin") != 0 {
286 t.Errorf("slot %d pool %d→%d payout %d", i, pool0, casGet(&casinoPool, "grin"), want)
287 }
288 if want > 0 {
289 wins++
290 }
291 paid += want
292 staked += 100
293 if casTotal("grin", players) != total0 {
294 t.Fatalf("conservation broken at slot %d", i)
295 }
296 }
297 if wins == 0 || wins == 60 {
298 t.Errorf("slots wins %d of 60", wins)
299 }
300 _ = paid
301 _ = staked
302}
303
304func TestCasino_SlotsEngine(t *testing.T) {
305 // strip composition
306 cnt := [10]int{}
307 for i := 0; i < len(slotsStrip); i++ {
308 cnt[slotsStrip[i]-'0']++
309 }
310 want := [10]int{1, 3, 4, 5, 6, 6, 7, 6, 1, 1}
311 if len(slotsStrip) != 40 || cnt != want {
312 t.Fatalf("strip %v", cnt)
313 }
314 // all wilds: every line pays wild ×5
315 var g [slotsCells]byte
316 if m, l := slotsLineMult(&g); m != 25*1000 || l != 25 {
317 t.Errorf("all wilds %d/%d", m, l)
318 }
319 // row 1 = seven seven wild seven coin, everything else coin/star mix that pays nothing
320 for i := range g {
321 g[i] = 9 // vault: never pays on a line
322 }
323 for c, s := range []byte{1, 1, 0, 1, 7} {
324 g[c*4+1] = s
325 }
326 if m, l := slotsLineMult(&g); m != 150 || l != 1 {
327 t.Errorf("seven×4 line: %d/%d", m, l)
328 }
329 // scatter / vault counting
330 g[0], g[5] = symScatter, symScatter
331 if slotsCount(&g, symScatter) != 2 {
332 t.Errorf("scatter count")
333 }
334 // Vectors shared with src/lib/game/__tests__/slots-engine.test.ts
335 s := newSlotsStream("seed-a", "client-a", "cr-vector")
336 vg := slotsSpinGrid(s)
337 vm, vl := slotsLineMult(&vg)
338 if got := slotsGridString(&vg) + "|" + casU(vm) + "|" + strconv.Itoa(vl); got != slotsVectorA {
339 t.Errorf("vector a: %s", got)
340 }
341 hg := [slotsCells]byte{}
342 for i := range hg {
343 hg[i] = 7
344 }
345 hg[0], hg[4], hg[8] = symVault, symVault, symVault
346 hs := newSlotsStream("seed-h", "client-h", "cr-vector")
347 tiles, played, full := slotsHoldAndSpin(hs, &hg)
348 if got := hsTilesString(&tiles) + "|" + strconv.Itoa(played) + "|" + strconv.FormatBool(full); got != slotsVectorH {
349 t.Errorf("vector h: %s", got)
350 }
351}
352
353const (
354 slotsVectorA = "64573216573221064579|0|0"
355 slotsVectorH = "1.0.0.1.1.0.0.0.1.0.0.0.0.0.0.0.0.0.0.0|4|false"
356)
357
358func TestCasino_Caps(cur realm, t *testing.T) {
359 casSetup()
360 casResetPool("grin", 19999)
361 mustPanic(t, "casino closed", func() { casinoCheckBet("grin", "coin_flip", 1) })
362 casResetPool("joy", 199) // rarity 1: 199 units < 200 units
363 mustPanic(t, "casino closed", func() { casinoCheckBet("joy", "coin_flip", 1) })
364 casResetPool("grin", 100000)
365 mustPanic(t, "bet too large", func() { casinoCheckBet("grin", "coin_flip", 501) })
366 casinoCheckBet("grin", "rps", 500)
367 // slots: 0.004% of 100,000 = 4 → no slots bet fits (bets are multiples of 25)
368 mustPanic(t, "bet too large", func() { casinoCheckBet("grin", "slots", 25) })
369 casResetPool("grin", 10_000_000) // 0.004% = 400
370 casinoCheckBet("grin", "slots", 400)
371 mustPanic(t, "multiple of 25", func() { casinoCheckBet("grin", "slots", 30) })
372 mustPanic(t, "bet too large", func() { casinoCheckBet("grin", "slots", 425) })
373 // open reservations ≤ 20% of the pool: 400×499 = 199,600 each → 10 fit in 2,000,000 … but the
374 // daily 10% (1,000,000) binds first: 5 fit.
375 casinoReserve("grin", 199600*5)
376 mustPanic(t, "daily payout limit", func() { casinoCheckBet("grin", "slots", 350) })
377 casResetPool("grin", 10_000_000)
378 casinoReserve("grin", 1_990_000)
379 mustPanic(t, "20 percent", func() { casinoCheckBet("grin", "coin_flip", 40_000) })
380}
381
382func TestCasino_DailyCap(cur realm, t *testing.T) {
383 casResetPool("grin", 100000)
384 _, snap, _, _ := casinoDayState("grin")
385 setCasinoDay("grin", casDayNow(), snap, 9800, 0)
386 mustPanic(t, "daily payout limit", func() { casinoCheckBet("grin", "coin_flip", 201) })
387 casinoCheckBet("grin", "coin_flip", 200)
388}
389
390func TestCasino_RefundAndForfeitTimeouts(cur realm, t *testing.T) {
391 adm := casSetup()
392 casResetPool("grin", 100000)
393 p := casAddr("cas-refund")
394 casSet(&balances, p.String()+":grin", 50)
395 // 1) s never revealed → refund after 30 minutes, never earlier.
396 testing.SetRealm(testing.NewUserRealm(adm))
397 CommitCasinoSeed(cross(cur), "cr-rf-1", p.String(), casSeedHash("r"))
398 testing.SetRealm(testing.NewUserRealm(p))
399 PlaceCasinoBet(cross(cur), "cr-rf-1", "coin_flip", "grin", "50", "heads", sha256Hex("c"))
400 casAbort(t, "early refund", revive(func() { RefundCasinoBet(cross(cur), "cr-rf-1") }), "refund opens")
401 testing.SkipHeights(361) // > 30 minutes
402 testing.SetRealm(testing.NewUserRealm(casAddr("cas-stranger")))
403 casAbort(t, "stranger refund", revive(func() { RefundCasinoBet(cross(cur), "cr-rf-1") }), "no bet")
404 testing.SetRealm(testing.NewUserRealm(p))
405 RefundCasinoBet(cross(cur), "cr-rf-1")
406 if casBalOf(p.String(), "grin") != 50 || casGet(&casinoReserved, "grin") != 0 || casGet(&casinoPool, "grin") != 100000 {
407 t.Errorf("refund: bal=%d reserved=%d pool=%d", casBalOf(p.String(), "grin"), casGet(&casinoReserved, "grin"), casGet(&casinoPool, "grin"))
408 }
409 casAbort(t, "double refund", revive(func() { RefundCasinoBet(cross(cur), "cr-rf-1") }), "already settled")
410 testing.SetRealm(testing.NewUserRealm(adm))
411 casAbort(t, "reveal after refund", revive(func() { RevealCasinoSeed(cross(cur), "cr-rf-1", "r") }), "closed")
412
413 // 2) s revealed, c withheld → forfeits as a loss after 24 h (anyone can call it).
414 CommitCasinoSeed(cross(cur), "cr-rf-2", p.String(), casSeedHash("r2"))
415 testing.SetRealm(testing.NewUserRealm(p))
416 PlaceCasinoBet(cross(cur), "cr-rf-2", "coin_flip", "grin", "50", "heads", sha256Hex("c2"))
417 testing.SetRealm(testing.NewUserRealm(adm))
418 RevealCasinoSeed(cross(cur), "cr-rf-2", "r2")
419 stranger := casAddr("cas-stranger")
420 testing.SetRealm(testing.NewUserRealm(stranger))
421 casAbort(t, "early forfeit", revive(func() { ForfeitCasinoBet(cross(cur), "cr-rf-2") }), "forfeit opens")
422 testing.SkipHeights(17281) // > 24 h
423 testing.SetRealm(testing.NewUserRealm(p))
424 casAbort(t, "refund instead of forfeit", revive(func() { RefundCasinoBet(cross(cur), "cr-rf-2") }), "no refunds")
425 testing.SetRealm(testing.NewUserRealm(stranger))
426 ForfeitCasinoBet(cross(cur), "cr-rf-2")
427 if casBalOf(p.String(), "grin") != 0 || casGet(&casinoPool, "grin") != 100050 || casGet(&casinoReserved, "grin") != 0 {
428 t.Errorf("forfeit: bal=%d pool=%d reserved=%d", casBalOf(p.String(), "grin"), casGet(&casinoPool, "grin"), casGet(&casinoReserved, "grin"))
429 }
430 casAbort(t, "secret after forfeit", revive(func() { RevealCasinoSecret(cross(cur), "cr-rf-2", "c2") }), "not waiting")
431 // 3) betting window: a commit older than 1 h takes no bet.
432 testing.SetRealm(testing.NewUserRealm(adm))
433 CommitCasinoSeed(cross(cur), "cr-rf-3", p.String(), casSeedHash("r3"))
434 testing.SkipHeights(721)
435 casSet(&balances, p.String()+":grin", 5)
436 testing.SetRealm(testing.NewUserRealm(p))
437 casAbort(t, "expired round", revive(func() {
438 PlaceCasinoBet(cross(cur), "cr-rf-3", "coin_flip", "grin", "5", "heads", sha256Hex("c3"))
439 }), "expired")
440}
441
442// Same vectors as src/lib/game/__tests__/casino-outcome.test.ts: realm and client roll alike.
443func TestCasino_RollVectors(t *testing.T) {
444 if r := CasinoRoll("seed-a", "client-a", "cr-vector", "higher_lower"); r != 23 {
445 t.Errorf("vector a: %d", r)
446 }
447 if r := CasinoRoll("seed-b", "client-b", "cr-vector", "coin_flip"); r != 65 {
448 t.Errorf("vector b: %d", r)
449 }
450}
451
452// Seeds ground offline with the same engine (.bot-notes/casino, engine mirror):
453// cr-g-25 3 scatters → 10 free spins, base win 240 at bet 100
454// cr-g-212 4 vaults → Hold & Spin 6× (+ lines 55 units) = 820 at bet 100
455// cr-g-8273 Hold & Spin with MAJOR (+ 28× incl. a MINI) = 2800 at bet 100
456// cr-g-29989 Hold & Spin with GRAND (9×) = 450 at bet 50
457func TestCasino_FreeSpinsGrant(cur realm, t *testing.T) {
458 adm := casSetup()
459 casResetBonus()
460 casResetPool("grin", 10_000_000)
461 p := casAddr("cas-fs")
462 casSet(&balances, p.String()+":grin", 100_000)
463 players := []address{p, adm}
464 total0 := casTotal("grin", players)
465
466 b := casPlay(cur, t, adm, p, "cr-g-25", "slots", "spin", 100)
467 if b.payout != 240 || b.scatters != 3 || b.fsGranted != 10 {
468 t.Fatalf("trigger spin payout %d scatters %d granted %d", b.payout, b.scatters, b.fsGranted)
469 }
470 g := getGrant(p.String())
471 if g == nil || g.left != 10 || g.bet != 100 || g.capLeft != 50000-240 {
472 t.Fatalf("grant %s", FreeSpinGrant(p.String()))
473 }
474 if casGet(&casinoReserved, "grin") != 50000-240 {
475 t.Fatalf("grant reservation %d", casGet(&casinoReserved, "grin"))
476 }
477 bal0 := casBalOf(p.String(), "grin")
478
479 // A paid slots spin waits until the free spins are used.
480 testing.SetRealm(testing.NewUserRealm(adm))
481 CommitCasinoSeed(cross(cur), "cr-f-paid", p.String(), casSeedHash("s-cr-f-paid"))
482 testing.SetRealm(testing.NewUserRealm(p))
483 casAbort(t, "paid spin during grant", revive(func() {
484 PlaceCasinoBet(cross(cur), "cr-f-paid", "slots", "grin", "100", "spin", sha256Hex("c-cr-f-paid"))
485 }), "finish your free spins")
486
487 // Only the grant owner can spin; refund of a stuck free spin gives the spin back.
488 testing.SetRealm(testing.NewUserRealm(adm))
489 CommitCasinoSeed(cross(cur), "cr-f-r", p.String(), casSeedHash("s-cr-f-r"))
490 testing.SetRealm(testing.NewUserRealm(p))
491 PlaceCasinoFreeSpin(cross(cur), "cr-f-r", sha256Hex("c-cr-f-r"))
492 testing.SetRealm(testing.NewUserRealm(adm))
493 CommitCasinoSeed(cross(cur), "cr-f-x", p.String(), casSeedHash("s-cr-f-x"))
494 testing.SetRealm(testing.NewUserRealm(p))
495 casAbort(t, "two free spins in flight", revive(func() {
496 PlaceCasinoFreeSpin(cross(cur), "cr-f-x", sha256Hex("c-cr-f-x"))
497 }), "already in progress")
498 testing.SkipHeights(361)
499 RefundCasinoBet(cross(cur), "cr-f-r")
500 if g.left != 10 || g.inRound != "" || casBalOf(p.String(), "grin") != bal0 {
501 t.Fatalf("free spin refund: %s bal %d", FreeSpinGrant(p.String()), casBalOf(p.String(), "grin"))
502 }
503
504 wants := []uint64{896, 0, 216, 0, 0, 0, 1640, 80, 0}
505 for j, want := range wants {
506 round := "cr-f-" + strconv.Itoa(j)
507 testing.SetRealm(testing.NewUserRealm(adm))
508 CommitCasinoSeed(cross(cur), round, p.String(), casSeedHash("s-"+round))
509 testing.SetRealm(testing.NewUserRealm(p))
510 before := casBalOf(p.String(), "grin")
511 PlaceCasinoFreeSpin(cross(cur), round, sha256Hex("c-"+round))
512 if casBalOf(p.String(), "grin") != before {
513 t.Fatalf("free spin took a stake")
514 }
515 testing.SetRealm(testing.NewUserRealm(adm))
516 RevealCasinoSeed(cross(cur), round, "s-"+round)
517 RevealCasinoSecret(cross(cur), round, "c-"+round)
518 fb := casinoBets.Get(round + ":" + p.String()).(*casinoBet)
519 if !fb.freeSpin || fb.payout != want || casBalOf(p.String(), "grin") != before+want || fb.fsGranted != 0 || fb.hsTiles != "" {
520 t.Errorf("free spin %d payout %d want %d", j, fb.payout, want)
521 }
522 if casTotal("grin", players) != total0 {
523 t.Fatalf("conservation broken at free spin %d", j)
524 }
525 if casGet(&casinoReserved, "grin") != g.capLeft {
526 t.Fatalf("reservation %d != capLeft %d", casGet(&casinoReserved, "grin"), g.capLeft)
527 }
528 }
529 if g.left != 1 || g.won != 2832 || g.capLeft != 50000-240-2832 {
530 t.Fatalf("grant after 9: %s", FreeSpinGrant(p.String()))
531 }
532 // The last free spin is never revealed by the player: forfeited after 24h, wins 0, grant closes.
533 testing.SetRealm(testing.NewUserRealm(adm))
534 CommitCasinoSeed(cross(cur), "cr-f-9", p.String(), casSeedHash("s-cr-f-9"))
535 testing.SetRealm(testing.NewUserRealm(p))
536 PlaceCasinoFreeSpin(cross(cur), "cr-f-9", sha256Hex("c-cr-f-9"))
537 testing.SetRealm(testing.NewUserRealm(adm))
538 RevealCasinoSeed(cross(cur), "cr-f-9", "s-cr-f-9")
539 testing.SkipHeights(17281)
540 testing.SetRealm(testing.NewUserRealm(casAddr("cas-anyone")))
541 ForfeitCasinoBet(cross(cur), "cr-f-9")
542 if getGrant(p.String()) != nil || FreeSpinGrant(p.String()) != "null" || casGet(&casinoReserved, "grin") != 0 {
543 t.Fatalf("grant not closed: %s reserved %d", FreeSpinGrant(p.String()), casGet(&casinoReserved, "grin"))
544 }
545 if casTotal("grin", players) != total0 {
546 t.Fatalf("conservation broken after the feature")
547 }
548 // Paid spins are open again.
549 testing.SetRealm(testing.NewUserRealm(adm))
550 CommitCasinoSeed(cross(cur), "cr-f-after", p.String(), casSeedHash("s-cr-f-after"))
551 testing.SetRealm(testing.NewUserRealm(p))
552 PlaceCasinoBet(cross(cur), "cr-f-after", "slots", "grin", "100", "spin", sha256Hex("c-cr-f-after"))
553 testing.SetRealm(testing.NewUserRealm(casAddr("cas-nogrant")))
554 casAbort(t, "free spin without grant", revive(func() {
555 PlaceCasinoFreeSpin(cross(cur), "cr-f-x", sha256Hex("c-cr-f-x"))
556 }), "another player")
557}
558
559func TestCasino_HoldAndSpinJackpots(cur realm, t *testing.T) {
560 adm := casSetup()
561 casResetBonus()
562 casResetPool("grin", 10_000_000)
563 p := casAddr("cas-hs")
564 casSet(&balances, p.String()+":grin", 100_000)
565 casSet(&balances, adm.String()+":grin", 50_000)
566 players := []address{p, adm}
567
568 // Only the admin seeds, from its own balance; seeds and ref bet are admin-only.
569 testing.SetRealm(testing.NewUserRealm(p))
570 casAbort(t, "non-admin seed", revive(func() { SeedCasinoJackpot(cross(cur), "grin", "major", "10") }), "admin only")
571 casAbort(t, "non-admin reset", revive(func() { SetCasinoJackpotSeed(cross(cur), "grin", "major", "10") }), "admin only")
572 testing.SetRealm(testing.NewUserRealm(adm))
573 casAbort(t, "bad tier", revive(func() { SeedCasinoJackpot(cross(cur), "grin", "mega", "10") }), "tier")
574 casAbort(t, "seed above balance", revive(func() { SeedCasinoJackpot(cross(cur), "grin", "grand", "60000") }), "")
575 SeedCasinoJackpot(cross(cur), "grin", "major", "5000")
576 SeedCasinoJackpot(cross(cur), "grin", "grand", "20000")
577 SetCasinoJackpotSeed(cross(cur), "grin", "major", "3000")
578 SetCasinoJackpotSeed(cross(cur), "grin", "grand", "10000")
579 if casBalOf(adm.String(), "grin") != 25000 || casJp("grin") != 25000 {
580 t.Fatalf("seeding moved %d", casJp("grin"))
581 }
582 if !strings.Contains(JackpotPool("grin"), `"pool":"20000"`) {
583 t.Errorf("JackpotPool should report GRAND: %s", JackpotPool("grin"))
584 }
585 total0 := casTotal("grin", players)
586
587 // Plain Hold & Spin: coins only, inside the 500× cap.
588 b := casPlay(cur, t, adm, p, "cr-g-212", "slots", "spin", 100)
589 if b.payout != 820 || b.hsMult != 6 || b.hsRespins != 3 || b.jackpots != "" || b.hsTiles != "0.1.0.0.3.0.0.0.0.0.0.0.0.0.0.1.0.0.1.0" {
590 t.Errorf("hold&spin payout %d mult %d respins %d tiles %s", b.payout, b.hsMult, b.hsRespins, b.hsTiles)
591 }
592 // Each paid spin feeds MAJOR 1% and GRAND 0.5% (remainders carried).
593 if casGet(&casinoJackpot, jpKey("grin", "major")) != 5001 || casGet(&casinoJpDust, jpKey("grin", "grand")) != 5000 {
594 t.Errorf("contribution major %d grand dust %d", casGet(&casinoJackpot, jpKey("grin", "major")), casGet(&casinoJpDust, jpKey("grin", "grand")))
595 }
596 if casTotal("grin", players) != total0 {
597 t.Fatalf("conservation broken after hold&spin")
598 }
599
600 // MAJOR at the reference bet: the whole pool (incl. this spin's 1), then reset to its seed from the house.
601 bal := casBalOf(p.String(), "grin")
602 house := casGet(&casinoPool, "grin")
603 b = casPlay(cur, t, adm, p, "cr-g-8273", "slots", "spin", 100)
604 if b.jackpots != "major" || b.jackpotWin != 5002 || b.payout != 2800+5002 || b.hsMult != 28 {
605 t.Errorf("major: jackpots %q win %d payout %d mult %d", b.jackpots, b.jackpotWin, b.payout, b.hsMult)
606 }
607 if casBalOf(p.String(), "grin") != bal-100+b.payout {
608 t.Errorf("major paid %d", casBalOf(p.String(), "grin")-bal)
609 }
610 if casGet(&casinoJackpot, jpKey("grin", "major")) != 3000 {
611 t.Errorf("major not reset to its seed: %d", casGet(&casinoJackpot, jpKey("grin", "major")))
612 }
613 // house: +100 stake −2800 win −1 major −1 grand(dust 5000+5000) −3000 reset
614 if casGet(&casinoPool, "grin") != house+100-2800-1-1-3000 {
615 t.Errorf("house after major %d → %d", house, casGet(&casinoPool, "grin"))
616 }
617 if casTotal("grin", players) != total0 {
618 t.Fatalf("conservation broken after major")
619 }
620
621 // GRAND at half the reference bet pays half the pool.
622 grand0 := casGet(&casinoJackpot, jpKey("grin", "grand"))
623 b = casPlay(cur, t, adm, p, "cr-g-29989", "slots", "spin", 50)
624 if b.jackpots != "grand" || b.jackpotWin != grand0/2 || b.payout != 450+grand0/2 {
625 t.Errorf("grand: jackpots %q win %d (pool %d) payout %d", b.jackpots, b.jackpotWin, grand0, b.payout)
626 }
627 if casGet(&casinoJackpot, jpKey("grin", "grand")) != 10001 {
628 // half stays (10000 + the 1 contributed) → above the 10000 seed, no top-up
629 t.Errorf("grand after hit %d", casGet(&casinoJackpot, jpKey("grin", "grand")))
630 }
631 if casTotal("grin", players) != total0 {
632 t.Fatalf("conservation broken after grand")
633 }
634 if !strings.Contains(CasinoJackpots("grin"), `"major":"3000"`) || !strings.Contains(CasinoBet("cr-g-29989", p.String()), `"jackpots":"grand"`) {
635 t.Errorf("queries: %s", CasinoJackpots("grin"))
636 }
637 if casGet(&casinoReserved, "grin") != 0 {
638 t.Errorf("reservations left %d", casGet(&casinoReserved, "grin"))
639 }
640}
641
642func TestCasino_HSPayoutRules(t *testing.T) {
643 var tiles [slotsCells]uint16
644 tiles[0], tiles[1], tiles[2], tiles[3] = 5, hsMini, hsMinor, hsMajor
645 tiles[4], tiles[5] = hsMajor, hsGrand
646 m, mj, gr := slotsHSPayout(&tiles, false)
647 if m != 5+20+75+75 || !mj || !gr {
648 t.Errorf("hs payout %d %v %v", m, mj, gr)
649 }
650 var full [slotsCells]uint16
651 for i := range full {
652 full[i] = 1
653 }
654 m, mj, gr = slotsHSPayout(&full, true)
655 if m != 20 || mj || !gr {
656 t.Errorf("blackout %d %v %v", m, mj, gr)
657 }
658}
659
660
661func TestCasino_OperatorIsSeparateKey(cur realm, t *testing.T) {
662 adm := casSetup()
663 casResetPool("grin", 100000)
664 op := casAddr("cas-op2")
665 p := casAddr("cas-op-player")
666 testing.SetRealm(testing.NewUserRealm(adm))
667 casAbort(t, "operator = admin", revive(func() { SetOperator(cross(cur), adm.String()) }), "separate key")
668 // bootstrap: no operator yet → the admin may commit
669 CommitCasinoSeed(cross(cur), "cr-op-boot", p.String(), casSeedHash("s"))
670 SetOperator(cross(cur), op.String())
671 casAbort(t, "admin after operator set", revive(func() {
672 CommitCasinoSeed(cross(cur), "cr-op-adm", p.String(), casSeedHash("s"))
673 }), "operator")
674 testing.SetRealm(testing.NewUserRealm(op))
675 CommitCasinoSeed(cross(cur), "cr-op-ok", p.String(), casSeedHash("s"))
676 operator = address("")
677}
678
679func TestCasino_StatsConservation(cur realm, t *testing.T) {
680 adm := casSetup()
681 casResetBonus()
682 casinoPool = avl.Tree{}
683 casinoReserved = avl.Tree{}
684 casinoDay = avl.Tree{}
685 casinoRounds = avl.Tree{}
686 casinoBets = avl.Tree{}
687 casSet(&balances, adm.String()+":grin", 10_100_000)
688 testing.SetRealm(testing.NewUserRealm(adm))
689 SeedCasinoPool(cross(cur), "grin", "10000000")
690 SeedCasinoJackpot(cross(cur), "grin", "major", "5000")
691 SeedCasinoJackpot(cross(cur), "grin", "grand", "20000")
692 p := casAddr("cas-stats")
693 casSet(&balances, p.String()+":grin", 1_000_000)
694 casPlay(cur, t, adm, p, "cr-g-25", "slots", "spin", 100) // grants 10 free spins
695 for j := 0; j < 9; j++ {
696 round := "cr-f-" + strconv.Itoa(j)
697 testing.SetRealm(testing.NewUserRealm(adm))
698 CommitCasinoSeed(cross(cur), round, p.String(), casSeedHash("s-"+round))
699 testing.SetRealm(testing.NewUserRealm(p))
700 PlaceCasinoFreeSpin(cross(cur), round, sha256Hex("c-"+round))
701 testing.SetRealm(testing.NewUserRealm(adm))
702 RevealCasinoSeed(cross(cur), round, "s-"+round)
703 RevealCasinoSecret(cross(cur), round, "c-"+round)
704 }
705 casPlay(cur, t, adm, p, "cr-st-cf", "coin_flip", "heads", 100)
706 casPlay(cur, t, adm, p, "cr-st-rps", "rps", "rock", 100)
707 st := CasinoStats("grin")
708 if !strings.Contains(st, `"conserved":true`) || !strings.Contains(st, `"seeded":"10025000"`) {
709 t.Errorf("stats: %s", st)
710 }
711 if !strings.Contains(st, `"slots":{"rounds":10,"staked":"100","paid":"`+casU(240+2832)+`"}`) {
712 t.Errorf("slots stats: %s", st)
713 }
714 if !strings.Contains(st, `"freeSpinsOpen":1`) {
715 t.Errorf("open grant: %s", st)
716 }
717}