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.

features_test.gno

27.06 Kb · 683 lines
  1package game32
  2
  3import (
  4	"chain"
  5	"strconv"
  6	"strings"
  7	"testing"
  8	"time"
  9)
 10
 11// ── Vouchers & replay ────────────────────────────────────────────────────────
 12
 13func TestVoucher_ValidAndReplay(cur realm, t *testing.T) {
 14	setup()
 15	p := ta("v-player")
 16	payload := `[["grin","50"]]`
 17	n, e, s := voucher(t, "claim", p, payload)
 18	testing.SetRealm(testing.NewUserRealm(p))
 19	out := ClaimAndMint(cross(cur), payload, n, e, s)
 20	if bal(p, "grin") != 50 || out != `{"grin":50}` {
 21		t.Errorf("claim: bal=%d out=%s", bal(p, "grin"), out)
 22	}
 23	if LastNonce(p.String()) != n {
 24		t.Errorf("nonce not recorded")
 25	}
 26	testing.SkipHeights(13)
 27	mustAbort(t, "replay", revive(func() { ClaimAndMint(cross(cur), payload, n, e, s) }), "already used")
 28	if bal(p, "grin") != 50 {
 29		t.Errorf("replay credited")
 30	}
 31}
 32
 33func TestVoucher_Rejections(cur realm, t *testing.T) {
 34	adm := setup()
 35	p, q := ta("v-p"), ta("v-q")
 36	payload := `[["grin","10"]]`
 37	now := time.Now().Unix()
 38
 39	// voucher for p used by q
 40	n, e, s := voucher(t, "claim", p, payload)
 41	testing.SetRealm(testing.NewUserRealm(q))
 42	mustAbort(t, "other player", revive(func() { ClaimAndMint(cross(cur), payload, n, e, s) }), "invalid voucher signature")
 43	// tampered payload
 44	testing.SetRealm(testing.NewUserRealm(p))
 45	mustAbort(t, "tampered", revive(func() { ClaimAndMint(cross(cur), `[["grin","99"]]`, n, e, s) }), "invalid voucher signature")
 46	// wrong kind (an achievement voucher can't be used to claim)
 47	sa := sigFor(t, "ach", p, n, e, payload)
 48	mustAbort(t, "wrong kind", revive(func() { ClaimAndMint(cross(cur), payload, n, e, sa) }), "invalid voucher signature")
 49	// expired
 50	se := sigFor(t, "claim", p, n, now-1, payload)
 51	mustAbort(t, "expired", revive(func() { ClaimAndMint(cross(cur), payload, n, now-1, se) }), "expired")
 52	// expiry too far out
 53	sf := sigFor(t, "claim", p, n, now+3600, payload)
 54	mustAbort(t, "far expiry", revive(func() { ClaimAndMint(cross(cur), payload, n, now+3600, sf) }), "too far")
 55	// kill switch
 56	testing.SetRealm(testing.NewUserRealm(adm))
 57	SetClaimSigner(cross(cur), "")
 58	testing.SetRealm(testing.NewUserRealm(p))
 59	mustAbort(t, "signer disabled", revive(func() { ClaimAndMint(cross(cur), payload, n, e, s) }), "no claim signer")
 60	testing.SetRealm(testing.NewUserRealm(p))
 61	mustAbort(t, "non-admin SetClaimSigner", revive(func() { SetClaimSigner(cross(cur), testSignerPub) }), "admin only")
 62	testing.SetRealm(testing.NewUserRealm(adm))
 63	SetClaimSigner(cross(cur), testSignerPub)
 64	testing.SetRealm(testing.NewUserRealm(p))
 65	ClaimAndMint(cross(cur), payload, n, e, s)
 66	if bal(p, "grin") != 10 || bal(q, "grin") != 0 {
 67		t.Errorf("p=%d q=%d", bal(p, "grin"), bal(q, "grin"))
 68	}
 69}
 70
 71// ── Rate caps ────────────────────────────────────────────────────────────────
 72
 73func TestRateCap_RefillAndCooldown(cur realm, t *testing.T) {
 74	setup()
 75	p := ta("rate-p")
 76	// hands dust rate = 12 × 2.00 (dmg) × 1.75 (year-plan dust headroom) = 42/min;
 77	// a fresh wallet starts with a 5-min bank = 210.
 78	payload := `[["grin","200"]]`
 79	n, e, s := voucher(t, "claim", p, payload)
 80	testing.SetRealm(testing.NewUserRealm(p))
 81	ClaimAndMint(cross(cur), payload, n, e, s)
 82
 83	payload = `[["grin","1"]]`
 84	n, e, s = voucher(t, "claim", p, payload)
 85	mustAbort(t, "cooldown", revive(func() { ClaimAndMint(cross(cur), payload, n, e, s) }), "too fast")
 86	testing.SkipHeights(13) // +65 s → 10 + 45 = 55 available
 87	payload = `[["grin","60"]]`
 88	n, e, s = voucher(t, "claim", p, payload)
 89	mustAbort(t, "over cap", revive(func() { ClaimAndMint(cross(cur), payload, n, e, s) }), "rate limit")
 90	testing.SkipHeights(13) // +65 s → 10 + 91 = 101
 91	payload = `[["grin","90"]]`
 92	n, e, s = voucher(t, "claim", p, payload)
 93	ClaimAndMint(cross(cur), payload, n, e, s)
 94	if bal(p, "grin") != 290 {
 95		t.Errorf("grin=%d want 290", bal(p, "grin"))
 96	}
 97	// the bank never exceeds 1 h of the tool's rate
 98	testing.SkipHeights(2000)
 99	if a := allowanceFor(p, 0); a != 2520 {
100		t.Errorf("bank cap = %d, want 2520", a)
101	}
102}
103
104func TestRateCap_VeinCap(cur realm, t *testing.T) {
105	adm := setup()
106	p := ta("vein-p")
107	testing.SetRealm(testing.NewUserRealm(adm))
108	SetVeinMax(cross(cur), "joy", strconv.FormatUint(veinMintedOf("joy")+30, 10))
109	payload := `[["joy","100"]]`
110	n, e, s := voucher(t, "claim", p, payload)
111	testing.SetRealm(testing.NewUserRealm(p))
112	out := ClaimAndMint(cross(cur), payload, n, e, s)
113	if bal(p, "joy") != 30 || out != `{"joy":30}` {
114		t.Errorf("vein cap not applied: %d %s", bal(p, "joy"), out)
115	}
116	testing.SetRealm(testing.NewUserRealm(adm))
117	mustAbort(t, "vein below minted", revive(func() { SetVeinMax(cross(cur), "joy", "1") }), "below")
118}
119
120// ── Gnome Pass payment ───────────────────────────────────────────────────────
121
122func TestPass_PaymentAndWithdraw(cur realm, t *testing.T) {
123	adm := setup()
124	p := ta("pass-buyer")
125	price := PassPrice()
126	testing.SetRealm(testing.NewUserRealm(p))
127	testing.SetOriginSend(chain.Coins{chain.NewCoin("ugnot", 1)})
128	mustAbort(t, "underpay", revive(func() { ActivateGnomePass(cross(cur), 30) }), "attach exactly")
129	testing.SetOriginSend(chain.Coins{chain.NewCoin("ugnot", price*2)})
130	mustAbort(t, "overpay", revive(func() { ActivateGnomePass(cross(cur), 30) }), "attach exactly")
131	mustAbort(t, "bad duration", revive(func() { ActivateGnomePass(cross(cur), 45) }), "durationDays")
132	testing.SetOriginSend(chain.Coins{chain.NewCoin("ugnot", price)})
133	ActivateGnomePass(cross(cur), 30)
134	now := time.Now().Unix()
135	if GnomePassExpiry(p.String()) != now+30*86400 {
136		t.Errorf("expiry %d", GnomePassExpiry(p.String()))
137	}
138	testing.SetOriginSend(chain.Coins{chain.NewCoin("ugnot", price*2)})
139	ActivateGnomePass(cross(cur), 60) // stacks
140	if GnomePassExpiry(p.String()) != now+90*86400 {
141		t.Errorf("stacked expiry %d", GnomePassExpiry(p.String()))
142	}
143	if PassRevenue() != price*3 {
144		t.Errorf("revenue %d", PassRevenue())
145	}
146	testing.SetOriginSend(nil)
147
148	// Withdraw (the bank deposited the attached ugnot into the realm account).
149	realmAddr := chain.PackageAddress(realmPkgPath)
150	testing.IssueCoins(realmAddr, chain.Coins{chain.NewCoin("ugnot", price*3)})
151	mustAbort(t, "non-admin withdraw", revive(func() { WithdrawFunds(cross(cur), p.String(), 1) }), "admin only")
152	testing.SetRealm(testing.NewUserRealm(adm))
153	out := ta("treasury-out")
154	WithdrawFunds(cross(cur), out.String(), price*3)
155	if TreasuryBalance() != 0 {
156		t.Errorf("realm balance after withdraw %d", TreasuryBalance())
157	}
158	mustAbort(t, "overdraw", revive(func() { WithdrawFunds(cross(cur), out.String(), 1) }), "unwithdrawn pass revenue")
159}
160
161// TestWithdrawFunds_CannotExceedPassRevenue verifies that WithdrawFunds is
162// strictly capped at passRevenueUgnot − adminGnotWithdrawn and cannot drain
163// any ugnot that arrived through other means (e.g. donations or future
164// features that hold player funds).
165func TestWithdrawFunds_CannotExceedPassRevenue(cur realm, t *testing.T) {
166	adm := setup()
167	realmAddr := chain.PackageAddress(realmPkgPath)
168
169	// Issue coins directly to the realm to simulate a "donation" beyond pass revenue.
170	// passRevenueUgnot starts at 0 (no passes sold in this sub-test).
171	donationAmount := int64(10000000) // 10 GNOT — not pass revenue
172	testing.IssueCoins(realmAddr, chain.Coins{chain.NewCoin("ugnot", donationAmount)})
173
174	// Admin must NOT be able to withdraw the donation — it exceeds pass revenue.
175	testing.SetRealm(testing.NewUserRealm(adm))
176	dst := ta("wd-dst")
177	mustAbort(t, "withdraw donation beyond revenue",
178		revive(func() { WithdrawFunds(cross(cur), dst.String(), 1) }),
179		"unwithdrawn pass revenue")
180
181	// Buy exactly one pass period so there is legitimate withdrawable revenue.
182	price := PassPrice()
183	buyer := ta("wd-buyer")
184	testing.SetRealm(testing.NewUserRealm(buyer))
185	testing.SetOriginSend(chain.Coins{chain.NewCoin("ugnot", price)})
186	ActivateGnomePass(cross(cur), 30)
187	testing.SetOriginSend(nil)
188	testing.IssueCoins(realmAddr, chain.Coins{chain.NewCoin("ugnot", price)})
189
190	// Withdrawing exactly the pass revenue must succeed.
191	testing.SetRealm(testing.NewUserRealm(adm))
192	WithdrawFunds(cross(cur), dst.String(), price)
193
194	// A second withdraw of even 1 ugnot must fail (revenue exhausted).
195	mustAbort(t, "second withdraw after full drain",
196		revive(func() { WithdrawFunds(cross(cur), dst.String(), 1) }),
197		"unwithdrawn pass revenue")
198
199	// Withdrawable() must report 0.
200	if got := WithdrawableFunds(); got != 0 {
201		t.Errorf("WithdrawableFunds after drain = %d, want 0", got)
202	}
203}
204
205// TestWithdrawProtocolFees_IsolatedFromCasinoAndPlayers verifies that
206// WithdrawProtocolFees can only move the admin's own accrued share of
207// protocol fees (adminFees[coin]) and cannot touch casinoPool or any player
208// balance, even when those are substantially funded.
209func TestWithdrawProtocolFees_IsolatedFromCasinoAndPlayers(cur realm, t *testing.T) {
210	restore := exOpen()
211	defer restore()
212
213	adm := setup()
214	player := ta("wpf-player")
215
216	// Seed the player with coins and the casino pool with grin via accrueProtocolFee.
217	give(player, "grin", 50000)
218	casinoGrinBefore := getU(&casinoPool, "grin")
219
220	// Accrue a known fee: accrueProtocolFee("grin", 200) splits 100 to adminFees,
221	// 100 (or 99 due to integer div) to casinoPool.
222	accrueProtocolFee("grin", 200)
223	adminShare := getU(&adminFees, "grin")
224	casinoShare := getU(&casinoPool, "grin") - casinoGrinBefore
225	if adminShare == 0 {
226		t.Fatal("expected non-zero adminFees after accrueProtocolFee")
227	}
228
229	// Non-admin must not be able to call WithdrawProtocolFees.
230	testing.SetRealm(testing.NewUserRealm(player))
231	mustAbort(t, "player fee withdraw",
232		revive(func() { WithdrawProtocolFees(cross(cur), "grin") }),
233		"admin only")
234
235	// Snapshot player and casino balances before admin withdrawal.
236	playerBalBefore := bal(player, "grin")
237	casinoAfterAccrue := getU(&casinoPool, "grin")
238	adminBalBefore := bal(adm, "grin")
239
240	// Admin withdraws their protocol fees.
241	testing.SetRealm(testing.NewUserRealm(adm))
242	withdrawn := WithdrawProtocolFees(cross(cur), "grin")
243
244	// Withdrawn amount must equal exactly the admin's accrued share.
245	if withdrawn != adminShare {
246		t.Errorf("withdrawn=%d, want adminShare=%d", withdrawn, adminShare)
247	}
248
249	// Admin balance must have increased by exactly withdrawn.
250	if got := bal(adm, "grin"); got != adminBalBefore+withdrawn {
251		t.Errorf("admin bal after = %d, want %d", got, adminBalBefore+withdrawn)
252	}
253
254	// Casino pool must be unchanged (not touched by the withdrawal).
255	if got := getU(&casinoPool, "grin"); got != casinoAfterAccrue {
256		t.Errorf("casinoPool after admin withdraw = %d, want %d (unchanged)", got, casinoAfterAccrue)
257	}
258
259	// Casino's incremental share must still be present.
260	if casinoShare > 0 && getU(&casinoPool, "grin") < casinoGrinBefore+casinoShare {
261		t.Errorf("casino lost coins during protocol fee withdrawal")
262	}
263
264	// Player balance must be unchanged.
265	if got := bal(player, "grin"); got != playerBalBefore {
266		t.Errorf("player bal after admin fee withdraw = %d, want %d (unchanged)", got, playerBalBefore)
267	}
268
269	// adminFees must now be zero for grin.
270	if got := getU(&adminFees, "grin"); got != 0 {
271		t.Errorf("adminFees[grin] after withdraw = %d, want 0", got)
272	}
273
274	// A second withdraw must panic (nothing to withdraw).
275	mustAbort(t, "double withdraw",
276		revive(func() { WithdrawProtocolFees(cross(cur), "grin") }),
277		"no fees to withdraw")
278}
279
280// ── PvP ──────────────────────────────────────────────────────────────────────
281
282func newMatch(cur realm, a, b address, game string, bet string) string {
283	testing.SetRealm(testing.NewUserRealm(a))
284	id := CreatePvpLobby(cross(cur), game, "grin", bet)
285	testing.SetRealm(testing.NewUserRealm(b))
286	JoinPvpLobby(cross(cur), id)
287	return id
288}
289
290// legacyLobby builds a lobby that escrowed a wager before wagers were switched off.
291func legacyLobby(cur realm, a address, bet uint64) string {
292	testing.SetRealm(testing.NewUserRealm(a))
293	id := CreatePvpLobby(cross(cur), "rps", "grin", "0")
294	debitBal(a.String(), "grin", bet)
295	lset(id, "bet", strconv.FormatUint(bet, 10))
296	pvpWagers.Set(id, "grin:"+strconv.FormatUint(bet, 10)+":"+a.String()+":")
297	return id
298}
299
300func winnerIs(id string, w address) bool {
301	return strings.Contains(PvpLobby(id), `"winner":"`+w.String()+`"`)
302}
303
304// Decision 5: PvP wagers are off until rewritten. A bet is refused; a lobby
305// escrowed before the switch can't be joined or revealed, only refunded.
306func TestPvp_WagersOff(cur realm, t *testing.T) {
307	setup()
308	a, b := ta("pvpw-a"), ta("pvpw-b")
309	give(a, "grin", 1000)
310	give(b, "grin", 1000)
311	testing.SetRealm(testing.NewUserRealm(a))
312	mustAbort(t, "wager lobby", revive(func() { CreatePvpLobby(cross(cur), "rps", "grin", "100") }), "wagers are off")
313	if bal(a, "grin") != 1000 {
314		t.Errorf("refused wager moved coins: %d", bal(a, "grin"))
315	}
316	id := legacyLobby(cur, a, 100)
317	testing.SetRealm(testing.NewUserRealm(b))
318	mustAbort(t, "join wagered lobby", revive(func() { JoinPvpLobby(cross(cur), id) }), "wagers are off")
319	// a legacy lobby that was already joined: reveal refused, timeout refunds both
320	id2 := legacyLobby(cur, a, 100)
321	debitBal(b.String(), "grin", 100)
322	pvpWagers.Set(id2, "grin:100:"+a.String()+":"+b.String())
323	lset(id2, "challenger", b.String())
324	lset(id2, "status", "joined")
325	testing.SetRealm(testing.NewUserRealm(a))
326	CommitPvpMove(cross(cur), id2, h("rock", "1"))
327	testing.SetRealm(testing.NewUserRealm(b))
328	CommitPvpMove(cross(cur), id2, h("paper", "2"))
329	mustAbort(t, "reveal wagered lobby", revive(func() { RevealPvpMove(cross(cur), id2, "paper", "2") }), "wagers are off")
330	testing.SkipHeights(121)
331	ClaimPvpTimeout(cross(cur), id2)
332	if bal(a, "grin") != 900 || bal(b, "grin") != 1000 {
333		t.Errorf("timeout refund: a=%d b=%d", bal(a, "grin"), bal(b, "grin"))
334	}
335	testing.SetRealm(testing.NewUserRealm(a))
336	CancelPvpLobby(cross(cur), id)
337	if bal(a, "grin") != 1000 {
338		t.Errorf("cancel refund: a=%d", bal(a, "grin"))
339	}
340}
341
342func TestPvp_HashedRoundPaysWinner(cur realm, t *testing.T) {
343	adm := setup()
344	a, b := ta("pvp-a"), ta("pvp-b")
345	give(a, "grin", 1000)
346	give(b, "grin", 1000)
347	_ = adm
348	id := newMatch(cur, a, b, "rps", "0")
349	if strings.Contains(OpenLobbies(), id) {
350		t.Errorf("joined lobby still listed as open")
351	}
352	testing.SetRealm(testing.NewUserRealm(a))
353	CommitPvpMove(cross(cur), id, h("rock", "sa"))
354	testing.SetRealm(testing.NewUserRealm(b))
355	mustAbort(t, "reveal before both commit", revive(func() { RevealPvpMove(cross(cur), id, "scissors", "sb") }), "both players")
356	CommitPvpMove(cross(cur), id, h("scissors", "sb"))
357	RevealPvpMove(cross(cur), id, "scissors", "sb")
358	testing.SetRealm(testing.NewUserRealm(a))
359	RevealPvpMove(cross(cur), id, "rock", "sa")
360	if !winnerIs(id, a) || bal(a, "grin") != 1000 || bal(b, "grin") != 1000 {
361		t.Errorf("winner/balances: %s a=%d b=%d", PvpLobby(id), bal(a, "grin"), bal(b, "grin"))
362	}
363	if !strings.Contains(PvpLobby(id), `"status":"finished"`) || !strings.Contains(PvpLobby(id), a.String()) {
364		t.Errorf("lobby %s", PvpLobby(id))
365	}
366}
367
368func TestPvp_InvalidChoiceForfeits(cur realm, t *testing.T) {
369	setup()
370	a, b := ta("pvpf-a"), ta("pvpf-b")
371	give(a, "grin", 100)
372	give(b, "grin", 100)
373	id := newMatch(cur, a, b, "rps", "0")
374	testing.SetRealm(testing.NewUserRealm(a))
375	CommitPvpMove(cross(cur), id, h("lizard", "x")) // not an rps move
376	testing.SetRealm(testing.NewUserRealm(b))
377	CommitPvpMove(cross(cur), id, h("rock", "y"))
378	testing.SetRealm(testing.NewUserRealm(a))
379	RevealPvpMove(cross(cur), id, "lizard", "x")
380	if !winnerIs(id, b) {
381		t.Errorf("forfeit: %s", PvpLobby(id))
382	}
383}
384
385func TestPvp_Timeouts(cur realm, t *testing.T) {
386	setup()
387	a, b, c, d := ta("pvpt-a"), ta("pvpt-b"), ta("pvpt-c"), ta("pvpt-d")
388	for _, x := range []address{a, b, c, d} {
389		give(x, "grin", 100)
390	}
391	// 1) only a commits, b stalls → a wins after the timeout
392	id := newMatch(cur, a, b, "rps", "0")
393	testing.SetRealm(testing.NewUserRealm(a))
394	CommitPvpMove(cross(cur), id, h("rock", "1"))
395	mustAbort(t, "early timeout", revive(func() { ClaimPvpTimeout(cross(cur), id) }), "not yet")
396	testing.SkipHeights(121)
397	ClaimPvpTimeout(cross(cur), id)
398	if !winnerIs(id, a) {
399		t.Errorf("committer timeout win: %s", PvpLobby(id))
400	}
401	// 2) nobody commits → refund both
402	id2 := newMatch(cur, c, d, "coin_flip", "0")
403	testing.SkipHeights(121)
404	testing.SetRealm(testing.NewUserRealm(d))
405	ClaimPvpTimeout(cross(cur), id2)
406	if bal(c, "grin") != 100 || bal(d, "grin") != 100 {
407		t.Errorf("refund: c=%d d=%d", bal(c, "grin"), bal(d, "grin"))
408	}
409	// 3) open lobby nobody joins → creator cancels and is refunded
410	id3 := legacyLobby(cur, c, 40)
411	testing.SetRealm(testing.NewUserRealm(d))
412	mustAbort(t, "cancel by other", revive(func() { CancelPvpLobby(cross(cur), id3) }), "only the creator")
413	testing.SetRealm(testing.NewUserRealm(c))
414	CancelPvpLobby(cross(cur), id3)
415	if bal(c, "grin") != 100 {
416		t.Errorf("cancel refund: c=%d", bal(c, "grin"))
417	}
418	testing.SetRealm(testing.NewUserRealm(d))
419	mustAbort(t, "join cancelled", revive(func() { JoinPvpLobby(cross(cur), id3) }), "not open")
420}
421
422func TestPvp_CoinFlipFairBit(cur realm, t *testing.T) {
423	setup()
424	a, b := ta("pvpc-a"), ta("pvpc-b")
425	give(a, "grin", 10)
426	give(b, "grin", 10)
427	id := newMatch(cur, a, b, "coin_flip", "0")
428	testing.SetRealm(testing.NewUserRealm(a))
429	CommitPvpMove(cross(cur), id, h("heads", "s1"))
430	testing.SetRealm(testing.NewUserRealm(b))
431	CommitPvpMove(cross(cur), id, h("tails", "s2"))
432	RevealPvpMove(cross(cur), id, "tails", "s2")
433	testing.SetRealm(testing.NewUserRealm(a))
434	RevealPvpMove(cross(cur), id, "heads", "s1")
435	x := sha256Sum("s1:s2")
436	outcome := "heads"
437	if (hexNibble(x[1]) & 1) == 1 {
438		outcome = "tails"
439	}
440	winner := a
441	if outcome == "tails" {
442		winner = b
443	}
444	if !winnerIs(id, winner) {
445		t.Errorf("outcome %s, winner bal %d (a=%d b=%d)", outcome, bal(winner, "grin"), bal(a, "grin"), bal(b, "grin"))
446	}
447}
448
449func hexNibble(c byte) byte {
450	if c >= 'a' {
451		return c - 'a' + 10
452	}
453	return c - '0'
454}
455
456// ── Casino ───────────────────────────────────────────────────────────────────
457
458
459
460// ── Achievements ─────────────────────────────────────────────────────────────
461
462func TestAchievements_AllowlistAndFixedRewards(cur realm, t *testing.T) {
463	setup()
464	p := ta("ach-p")
465	pl := `["blocks_1"]`
466	n, e, s := voucher(t, "ach", p, pl)
467	testing.SetRealm(testing.NewUserRealm(p))
468	ClaimAchievement(cross(cur), "blocks_1", n, e, s)
469	if bal(p, "grin") != 4 || !HasAchievement(p.String(), "blocks_1") {
470		t.Errorf("blocks_1 reward: grin=%d", bal(p, "grin"))
471	}
472	n, e, s = voucher(t, "ach", p, pl)
473	testing.SetRealm(testing.NewUserRealm(p))
474	mustAbort(t, "claim twice", revive(func() { ClaimAchievement(cross(cur), "blocks_1", n, e, s) }), "already claimed")
475	// grind medal needs real lifetime mining
476	gp := `["g.grin.10"]`
477	n, e, s = voucher(t, "ach", p, gp)
478	testing.SetRealm(testing.NewUserRealm(p))
479	mustAbort(t, "unearned grind", revive(func() { ClaimAchievement(cross(cur), "g.grin.10", n, e, s) }), "not earned")
480	cp := `[["grin","20"]]`
481	n, e, s = voucher(t, "claim", p, cp)
482	testing.SetRealm(testing.NewUserRealm(p))
483	ClaimAndMint(cross(cur), cp, n, e, s)
484	bp := `["g.grin.10","g.grin.100","nope"]`
485	n, e, s = voucher(t, "ach", p, bp)
486	testing.SetRealm(testing.NewUserRealm(p))
487	if got := BatchClaimAchievements(cross(cur), bp, n, e, s); got != 1 {
488		t.Errorf("batch claimed %d, want 1 (g.grin.10 only)", got)
489	}
490	if bal(p, "grin") != 4+20+10 {
491		t.Errorf("grin=%d want 34", bal(p, "grin"))
492	}
493	// medal credits: claiming g.grin.10 (and blocks_1) does NOT change
494	// LifetimeMined or the miners leaderboard; rewards have their own counter.
495	if got := getU(&lifetimeMined, p.String()+":grin"); got != 20 {
496		t.Errorf("lifetimeMined grin=%d want 20 (real claims only)", got)
497	}
498	if got := getU(&rewarded, p.String()+":grin"); got != 14 {
499		t.Errorf("rewarded grin=%d want 14", got)
500	}
501	if got := getU(&miners, p.String()); got != 20 {
502		t.Errorf("miners=%d want 20", got)
503	}
504	if !strings.Contains(LifetimeMined(p.String()), `"grin":20`) || !strings.Contains(Rewarded(p.String()), `"grin":14`) {
505		t.Errorf("views: %s / %s", LifetimeMined(p.String()), Rewarded(p.String()))
506	}
507}
508
509// Vein sizes follow the year-plan rule; crossing 50% is exposed/emitted;
510// exhaustion caps credits (the client reports the shortfall).
511func TestVein_ResizedAndHalfFlag(cur realm, t *testing.T) {
512	adm := setup()
513	var r1, r6 string
514	for c, r := range coinRarity {
515		if r == 1 && r1 == "" && c != "grin" {
516			r1 = c
517		}
518		if r == 6 && r6 == "" {
519			r6 = c
520		}
521	}
522	if veinMax(r1) != 82_000_000 || veinMax(r6) != 85_000_000 || veinMax("grin") != 102_000_000 {
523		t.Errorf("vein sizes r1 %d r6 %d grin %d", veinMax(r1), veinMax(r6), veinMax("grin"))
524	}
525	testing.SetRealm(testing.NewUserRealm(adm))
526	SetVeinMax(cross(cur), "joy", strconv.FormatUint(veinMintedOf("joy")+100, 10))
527	if strings.Contains(VeinSupply("joy"), `"exhausted":true`) {
528		t.Errorf("fresh vein exhausted: %s", VeinSupply("joy"))
529	}
530	takeFromVein("joy", 60)
531	if !strings.Contains(VeinSupply("joy"), `"exhausted":false`) {
532		t.Errorf("not exhausted yet: %s", VeinSupply("joy"))
533	}
534	if got := takeFromVein("joy", 60); got != 40 {
535		t.Errorf("overflow credited %d want 40", got)
536	}
537	if !strings.Contains(VeinSupply("joy"), `"halfMinted":true`) || !strings.Contains(VeinSupply("joy"), `"exhausted":true`) {
538		t.Errorf("flags: %s", VeinSupply("joy"))
539	}
540}
541
542// ── Migration ────────────────────────────────────────────────────────────────
543
544func TestMigration_IdempotentAndFinal(cur realm, t *testing.T) {
545	adm := setup()
546	u := ta("mig-user").String()
547	rows := `[["` + u + `","coffee","500"],["` + u + `","bogus","9"]]`
548	testing.SetRealm(testing.NewUserRealm(ta("mig-eve")))
549	mustAbort(t, "non-admin migrate", revive(func() { MigrateBatch(cross(cur), "b1", "balance", rows) }), "admin only")
550	testing.SetRealm(testing.NewUserRealm(adm))
551	v0 := veinMintedOf("coffee")
552	if n := MigrateBatch(cross(cur), "b1", "balance", rows); n != 1 {
553		t.Errorf("applied %d rows, want 1", n)
554	}
555	if n := MigrateBatch(cross(cur), "b1", "balance", rows); n != 0 {
556		t.Errorf("re-run applied %d", n)
557	}
558	MigrateBatch(cross(cur), "b1-again", "balance", rows) // same rows, new id: SET semantics
559	if balOf(u, "coffee") != 500 || veinMintedOf("coffee")-v0 != 500 {
560		t.Errorf("not idempotent: bal=%d vein+%d", balOf(u, "coffee"), veinMintedOf("coffee")-v0)
561	}
562	MigrateBatch(cross(cur), "t1", "tools", `[["`+u+`","starter-twig,twig,laser-of-doom"]]`)
563	MigrateBatch(cross(cur), "m1", "medal", `[["`+u+`","blocks_1"],["`+u+`","made-up"]]`)
564	MigrateBatch(cross(cur), "l1", "lifetime", `[["`+u+`","grin","1000"]]`)
565	MigrateBatch(cross(cur), "l2", "lifetime", `[["`+u+`","grin","1000"]]`)
566	if OwnedTools(u) != `["starter-twig","twig"]` || !HasAchievement(u, "blocks_1") || HasAchievement(u, "made-up") || getU(&miners, u) != 1000 {
567		t.Errorf("tools=%s medals=%s miners=%d", OwnedTools(u), GetMedals(u), getU(&miners, u))
568	}
569	FinalizeMigration(cross(cur))
570	mustAbort(t, "after finalize", revive(func() { MigrateBatch(cross(cur), "b2", "balance", rows) }), "finalized")
571	if !strings.Contains(MigrationStatus(), `"finalized":true`) {
572		t.Errorf("status %s", MigrationStatus())
573	}
574}
575
576// ── Misc fixes ───────────────────────────────────────────────────────────────
577
578func TestMisc_BuyToolLifeLeaderboardJsonRender(cur realm, t *testing.T) {
579	setup()
580	p := ta("misc-p")
581	give(p, "grin", 1000)
582	testing.SetRealm(testing.NewUserRealm(p))
583	BuyTool(cross(cur), "starter-twig")
584	for _, c := range toolCosts["twig"] {
585		n, _ := strconv.ParseUint(c[1], 10, 64)
586		give(p, c[0], n)
587	}
588	BuyTool(cross(cur), "twig")
589	if toolMaxLife["twig"] > 0 && getU(&toolLife, p.String()+":twig") != effectiveMaxLife(p.String(), "twig") {
590		t.Errorf("BuyTool did not set toolLife: %s", ToolLife(p.String()))
591	}
592	mustAbort(t, "buy twice", revive(func() { BuyTool(cross(cur), "twig") }), "already own")
593
594	setU(&miners, ta("lb-small").String(), 5)
595	setU(&miners, ta("lb-big").String(), 5_000_000)
596	lb := AllMiners(200, 0)
597	if !strings.HasPrefix(lb, `[{"address":"`+ta("lb-big").String()) {
598		t.Errorf("leaderboard not sorted by score: %s", lb[:120])
599	}
600
601	g := ta("misc-guild")
602	testing.SetRealm(testing.NewUserRealm(g))
603	gid := CreateGuild(cross(cur), `Evil","leader":"x`)
604	info := GuildInfo(gid)
605	if strings.Contains(info, `"leader":"x"`) {
606		t.Errorf("guild JSON injection: %s", info)
607	}
608	if !strings.Contains(Render(""), "game29") {
609		t.Errorf("Render: %s", Render(""))
610	}
611}
612
613// ── PlayerXp ─────────────────────────────────────────────────────────────────
614
615func TestPlayerXp_AccumulatesOnClaim(cur realm, t *testing.T) {
616	setup()
617	p := ta("xp-player")
618
619	// Baseline: fresh player has 0 XP.
620	if got := PlayerXp(p.String()); got != "0" {
621		t.Errorf("initial XP want 0 got %s", got)
622	}
623
624	// Claim 10 grin (rarity 0 → 3 XP each) = 30 XP.
625	payload := `[["grin","10"]]`
626	n, e, s := voucher(t, "claim", p, payload)
627	testing.SetRealm(testing.NewUserRealm(p))
628	ClaimAndMint(cross(cur), payload, n, e, s)
629
630	wantXp := uint64(10 * 3) // 10 coins × 3 XP (dust rarity)
631	gotXp := getU(&playerXp, p.String())
632	if gotXp != wantXp {
633		t.Errorf("after grin claim: want XP %d got %d (PlayerXp=%s)", wantXp, gotXp, PlayerXp(p.String()))
634	}
635
636	// PlayerXp query must return the same value as a decimal string.
637	if PlayerXp(p.String()) != strconv.FormatUint(wantXp, 10) {
638		t.Errorf("PlayerXp query mismatch: want %d got %s", wantXp, PlayerXp(p.String()))
639	}
640
641	// Upgrade to stone tool (maxRarity=2) so the second claim can mine music.
642	// Chain: hands → starter-twig → twig → wood → stone.
643	testing.SetRealm(testing.NewUserRealm(p))
644	for _, cost := range toolCosts["starter-twig"] {
645		n, _ := strconv.ParseUint(cost[1], 10, 64)
646		give(p, cost[0], n)
647	}
648	BuyTool(cross(cur), "starter-twig")
649	for _, cost := range toolCosts["twig"] {
650		n, _ := strconv.ParseUint(cost[1], 10, 64)
651		give(p, cost[0], n)
652	}
653	BuyTool(cross(cur), "twig")
654	for _, cost := range toolCosts["wood"] {
655		n, _ := strconv.ParseUint(cost[1], 10, 64)
656		give(p, cost[0], n)
657	}
658	BuyTool(cross(cur), "wood")
659	for _, cost := range toolCosts["stone"] {
660		n, _ := strconv.ParseUint(cost[1], 10, 64)
661		give(p, cost[0], n)
662	}
663	BuyTool(cross(cur), "stone")
664
665	// Second claim: add a higher-rarity coin. music is rarity 2 → 20 XP each.
666	testing.SkipHeights(13) // skip cooldown
667	payload2 := `[["music","5"]]`
668	n2, e2, s2 := voucher(t, "claim", p, payload2)
669	testing.SetRealm(testing.NewUserRealm(p))
670	ClaimAndMint(cross(cur), payload2, n2, e2, s2)
671
672	wantXp2 := wantXp + uint64(5*20) // +100 XP
673	gotXp2 := getU(&playerXp, p.String())
674	if gotXp2 != wantXp2 {
675		t.Errorf("after music claim: want XP %d got %d", wantXp2, gotXp2)
676	}
677
678	// XP accumulates across players independently.
679	q := ta("xp-q")
680	if PlayerXp(q.String()) != "0" {
681		t.Errorf("unrelated player should have 0 XP, got %s", PlayerXp(q.String()))
682	}
683}