Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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}