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

22.42 Kb · 564 lines
  1package game31
  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// ── PvP ──────────────────────────────────────────────────────────────────────
162
163func newMatch(cur realm, a, b address, game string, bet string) string {
164	testing.SetRealm(testing.NewUserRealm(a))
165	id := CreatePvpLobby(cross(cur), game, "grin", bet)
166	testing.SetRealm(testing.NewUserRealm(b))
167	JoinPvpLobby(cross(cur), id)
168	return id
169}
170
171// legacyLobby builds a lobby that escrowed a wager before wagers were switched off.
172func legacyLobby(cur realm, a address, bet uint64) string {
173	testing.SetRealm(testing.NewUserRealm(a))
174	id := CreatePvpLobby(cross(cur), "rps", "grin", "0")
175	debitBal(a.String(), "grin", bet)
176	lset(id, "bet", strconv.FormatUint(bet, 10))
177	pvpWagers.Set(id, "grin:"+strconv.FormatUint(bet, 10)+":"+a.String()+":")
178	return id
179}
180
181func winnerIs(id string, w address) bool {
182	return strings.Contains(PvpLobby(id), `"winner":"`+w.String()+`"`)
183}
184
185// Decision 5: PvP wagers are off until rewritten. A bet is refused; a lobby
186// escrowed before the switch can't be joined or revealed, only refunded.
187func TestPvp_WagersOff(cur realm, t *testing.T) {
188	setup()
189	a, b := ta("pvpw-a"), ta("pvpw-b")
190	give(a, "grin", 1000)
191	give(b, "grin", 1000)
192	testing.SetRealm(testing.NewUserRealm(a))
193	mustAbort(t, "wager lobby", revive(func() { CreatePvpLobby(cross(cur), "rps", "grin", "100") }), "wagers are off")
194	if bal(a, "grin") != 1000 {
195		t.Errorf("refused wager moved coins: %d", bal(a, "grin"))
196	}
197	id := legacyLobby(cur, a, 100)
198	testing.SetRealm(testing.NewUserRealm(b))
199	mustAbort(t, "join wagered lobby", revive(func() { JoinPvpLobby(cross(cur), id) }), "wagers are off")
200	// a legacy lobby that was already joined: reveal refused, timeout refunds both
201	id2 := legacyLobby(cur, a, 100)
202	debitBal(b.String(), "grin", 100)
203	pvpWagers.Set(id2, "grin:100:"+a.String()+":"+b.String())
204	lset(id2, "challenger", b.String())
205	lset(id2, "status", "joined")
206	testing.SetRealm(testing.NewUserRealm(a))
207	CommitPvpMove(cross(cur), id2, h("rock", "1"))
208	testing.SetRealm(testing.NewUserRealm(b))
209	CommitPvpMove(cross(cur), id2, h("paper", "2"))
210	mustAbort(t, "reveal wagered lobby", revive(func() { RevealPvpMove(cross(cur), id2, "paper", "2") }), "wagers are off")
211	testing.SkipHeights(121)
212	ClaimPvpTimeout(cross(cur), id2)
213	if bal(a, "grin") != 900 || bal(b, "grin") != 1000 {
214		t.Errorf("timeout refund: a=%d b=%d", bal(a, "grin"), bal(b, "grin"))
215	}
216	testing.SetRealm(testing.NewUserRealm(a))
217	CancelPvpLobby(cross(cur), id)
218	if bal(a, "grin") != 1000 {
219		t.Errorf("cancel refund: a=%d", bal(a, "grin"))
220	}
221}
222
223func TestPvp_HashedRoundPaysWinner(cur realm, t *testing.T) {
224	adm := setup()
225	a, b := ta("pvp-a"), ta("pvp-b")
226	give(a, "grin", 1000)
227	give(b, "grin", 1000)
228	_ = adm
229	id := newMatch(cur, a, b, "rps", "0")
230	if strings.Contains(OpenLobbies(), id) {
231		t.Errorf("joined lobby still listed as open")
232	}
233	testing.SetRealm(testing.NewUserRealm(a))
234	CommitPvpMove(cross(cur), id, h("rock", "sa"))
235	testing.SetRealm(testing.NewUserRealm(b))
236	mustAbort(t, "reveal before both commit", revive(func() { RevealPvpMove(cross(cur), id, "scissors", "sb") }), "both players")
237	CommitPvpMove(cross(cur), id, h("scissors", "sb"))
238	RevealPvpMove(cross(cur), id, "scissors", "sb")
239	testing.SetRealm(testing.NewUserRealm(a))
240	RevealPvpMove(cross(cur), id, "rock", "sa")
241	if !winnerIs(id, a) || bal(a, "grin") != 1000 || bal(b, "grin") != 1000 {
242		t.Errorf("winner/balances: %s a=%d b=%d", PvpLobby(id), bal(a, "grin"), bal(b, "grin"))
243	}
244	if !strings.Contains(PvpLobby(id), `"status":"finished"`) || !strings.Contains(PvpLobby(id), a.String()) {
245		t.Errorf("lobby %s", PvpLobby(id))
246	}
247}
248
249func TestPvp_InvalidChoiceForfeits(cur realm, t *testing.T) {
250	setup()
251	a, b := ta("pvpf-a"), ta("pvpf-b")
252	give(a, "grin", 100)
253	give(b, "grin", 100)
254	id := newMatch(cur, a, b, "rps", "0")
255	testing.SetRealm(testing.NewUserRealm(a))
256	CommitPvpMove(cross(cur), id, h("lizard", "x")) // not an rps move
257	testing.SetRealm(testing.NewUserRealm(b))
258	CommitPvpMove(cross(cur), id, h("rock", "y"))
259	testing.SetRealm(testing.NewUserRealm(a))
260	RevealPvpMove(cross(cur), id, "lizard", "x")
261	if !winnerIs(id, b) {
262		t.Errorf("forfeit: %s", PvpLobby(id))
263	}
264}
265
266func TestPvp_Timeouts(cur realm, t *testing.T) {
267	setup()
268	a, b, c, d := ta("pvpt-a"), ta("pvpt-b"), ta("pvpt-c"), ta("pvpt-d")
269	for _, x := range []address{a, b, c, d} {
270		give(x, "grin", 100)
271	}
272	// 1) only a commits, b stalls → a wins after the timeout
273	id := newMatch(cur, a, b, "rps", "0")
274	testing.SetRealm(testing.NewUserRealm(a))
275	CommitPvpMove(cross(cur), id, h("rock", "1"))
276	mustAbort(t, "early timeout", revive(func() { ClaimPvpTimeout(cross(cur), id) }), "not yet")
277	testing.SkipHeights(121)
278	ClaimPvpTimeout(cross(cur), id)
279	if !winnerIs(id, a) {
280		t.Errorf("committer timeout win: %s", PvpLobby(id))
281	}
282	// 2) nobody commits → refund both
283	id2 := newMatch(cur, c, d, "coin_flip", "0")
284	testing.SkipHeights(121)
285	testing.SetRealm(testing.NewUserRealm(d))
286	ClaimPvpTimeout(cross(cur), id2)
287	if bal(c, "grin") != 100 || bal(d, "grin") != 100 {
288		t.Errorf("refund: c=%d d=%d", bal(c, "grin"), bal(d, "grin"))
289	}
290	// 3) open lobby nobody joins → creator cancels and is refunded
291	id3 := legacyLobby(cur, c, 40)
292	testing.SetRealm(testing.NewUserRealm(d))
293	mustAbort(t, "cancel by other", revive(func() { CancelPvpLobby(cross(cur), id3) }), "only the creator")
294	testing.SetRealm(testing.NewUserRealm(c))
295	CancelPvpLobby(cross(cur), id3)
296	if bal(c, "grin") != 100 {
297		t.Errorf("cancel refund: c=%d", bal(c, "grin"))
298	}
299	testing.SetRealm(testing.NewUserRealm(d))
300	mustAbort(t, "join cancelled", revive(func() { JoinPvpLobby(cross(cur), id3) }), "not open")
301}
302
303func TestPvp_CoinFlipFairBit(cur realm, t *testing.T) {
304	setup()
305	a, b := ta("pvpc-a"), ta("pvpc-b")
306	give(a, "grin", 10)
307	give(b, "grin", 10)
308	id := newMatch(cur, a, b, "coin_flip", "0")
309	testing.SetRealm(testing.NewUserRealm(a))
310	CommitPvpMove(cross(cur), id, h("heads", "s1"))
311	testing.SetRealm(testing.NewUserRealm(b))
312	CommitPvpMove(cross(cur), id, h("tails", "s2"))
313	RevealPvpMove(cross(cur), id, "tails", "s2")
314	testing.SetRealm(testing.NewUserRealm(a))
315	RevealPvpMove(cross(cur), id, "heads", "s1")
316	x := sha256Sum("s1:s2")
317	outcome := "heads"
318	if (hexNibble(x[1]) & 1) == 1 {
319		outcome = "tails"
320	}
321	winner := a
322	if outcome == "tails" {
323		winner = b
324	}
325	if !winnerIs(id, winner) {
326		t.Errorf("outcome %s, winner bal %d (a=%d b=%d)", outcome, bal(winner, "grin"), bal(a, "grin"), bal(b, "grin"))
327	}
328}
329
330func hexNibble(c byte) byte {
331	if c >= 'a' {
332		return c - 'a' + 10
333	}
334	return c - '0'
335}
336
337// ── Casino ───────────────────────────────────────────────────────────────────
338
339
340
341// ── Achievements ─────────────────────────────────────────────────────────────
342
343func TestAchievements_AllowlistAndFixedRewards(cur realm, t *testing.T) {
344	setup()
345	p := ta("ach-p")
346	pl := `["blocks_1"]`
347	n, e, s := voucher(t, "ach", p, pl)
348	testing.SetRealm(testing.NewUserRealm(p))
349	ClaimAchievement(cross(cur), "blocks_1", n, e, s)
350	if bal(p, "grin") != 4 || !HasAchievement(p.String(), "blocks_1") {
351		t.Errorf("blocks_1 reward: grin=%d", bal(p, "grin"))
352	}
353	n, e, s = voucher(t, "ach", p, pl)
354	testing.SetRealm(testing.NewUserRealm(p))
355	mustAbort(t, "claim twice", revive(func() { ClaimAchievement(cross(cur), "blocks_1", n, e, s) }), "already claimed")
356	// grind medal needs real lifetime mining
357	gp := `["g.grin.10"]`
358	n, e, s = voucher(t, "ach", p, gp)
359	testing.SetRealm(testing.NewUserRealm(p))
360	mustAbort(t, "unearned grind", revive(func() { ClaimAchievement(cross(cur), "g.grin.10", n, e, s) }), "not earned")
361	cp := `[["grin","20"]]`
362	n, e, s = voucher(t, "claim", p, cp)
363	testing.SetRealm(testing.NewUserRealm(p))
364	ClaimAndMint(cross(cur), cp, n, e, s)
365	bp := `["g.grin.10","g.grin.100","nope"]`
366	n, e, s = voucher(t, "ach", p, bp)
367	testing.SetRealm(testing.NewUserRealm(p))
368	if got := BatchClaimAchievements(cross(cur), bp, n, e, s); got != 1 {
369		t.Errorf("batch claimed %d, want 1 (g.grin.10 only)", got)
370	}
371	if bal(p, "grin") != 4+20+10 {
372		t.Errorf("grin=%d want 34", bal(p, "grin"))
373	}
374	// medal credits: claiming g.grin.10 (and blocks_1) does NOT change
375	// LifetimeMined or the miners leaderboard; rewards have their own counter.
376	if got := getU(&lifetimeMined, p.String()+":grin"); got != 20 {
377		t.Errorf("lifetimeMined grin=%d want 20 (real claims only)", got)
378	}
379	if got := getU(&rewarded, p.String()+":grin"); got != 14 {
380		t.Errorf("rewarded grin=%d want 14", got)
381	}
382	if got := getU(&miners, p.String()); got != 20 {
383		t.Errorf("miners=%d want 20", got)
384	}
385	if !strings.Contains(LifetimeMined(p.String()), `"grin":20`) || !strings.Contains(Rewarded(p.String()), `"grin":14`) {
386		t.Errorf("views: %s / %s", LifetimeMined(p.String()), Rewarded(p.String()))
387	}
388}
389
390// Vein sizes follow the year-plan rule; crossing 50% is exposed/emitted;
391// exhaustion caps credits (the client reports the shortfall).
392func TestVein_ResizedAndHalfFlag(cur realm, t *testing.T) {
393	adm := setup()
394	var r1, r6 string
395	for c, r := range coinRarity {
396		if r == 1 && r1 == "" && c != "grin" {
397			r1 = c
398		}
399		if r == 6 && r6 == "" {
400			r6 = c
401		}
402	}
403	if veinMax(r1) != 82_000_000 || veinMax(r6) != 85_000_000 || veinMax("grin") != 102_000_000 {
404		t.Errorf("vein sizes r1 %d r6 %d grin %d", veinMax(r1), veinMax(r6), veinMax("grin"))
405	}
406	testing.SetRealm(testing.NewUserRealm(adm))
407	SetVeinMax(cross(cur), "joy", strconv.FormatUint(veinMintedOf("joy")+100, 10))
408	if strings.Contains(VeinSupply("joy"), `"exhausted":true`) {
409		t.Errorf("fresh vein exhausted: %s", VeinSupply("joy"))
410	}
411	takeFromVein("joy", 60)
412	if !strings.Contains(VeinSupply("joy"), `"exhausted":false`) {
413		t.Errorf("not exhausted yet: %s", VeinSupply("joy"))
414	}
415	if got := takeFromVein("joy", 60); got != 40 {
416		t.Errorf("overflow credited %d want 40", got)
417	}
418	if !strings.Contains(VeinSupply("joy"), `"halfMinted":true`) || !strings.Contains(VeinSupply("joy"), `"exhausted":true`) {
419		t.Errorf("flags: %s", VeinSupply("joy"))
420	}
421}
422
423// ── Migration ────────────────────────────────────────────────────────────────
424
425func TestMigration_IdempotentAndFinal(cur realm, t *testing.T) {
426	adm := setup()
427	u := ta("mig-user").String()
428	rows := `[["` + u + `","coffee","500"],["` + u + `","bogus","9"]]`
429	testing.SetRealm(testing.NewUserRealm(ta("mig-eve")))
430	mustAbort(t, "non-admin migrate", revive(func() { MigrateBatch(cross(cur), "b1", "balance", rows) }), "admin only")
431	testing.SetRealm(testing.NewUserRealm(adm))
432	v0 := veinMintedOf("coffee")
433	if n := MigrateBatch(cross(cur), "b1", "balance", rows); n != 1 {
434		t.Errorf("applied %d rows, want 1", n)
435	}
436	if n := MigrateBatch(cross(cur), "b1", "balance", rows); n != 0 {
437		t.Errorf("re-run applied %d", n)
438	}
439	MigrateBatch(cross(cur), "b1-again", "balance", rows) // same rows, new id: SET semantics
440	if balOf(u, "coffee") != 500 || veinMintedOf("coffee")-v0 != 500 {
441		t.Errorf("not idempotent: bal=%d vein+%d", balOf(u, "coffee"), veinMintedOf("coffee")-v0)
442	}
443	MigrateBatch(cross(cur), "t1", "tools", `[["`+u+`","starter-twig,twig,laser-of-doom"]]`)
444	MigrateBatch(cross(cur), "m1", "medal", `[["`+u+`","blocks_1"],["`+u+`","made-up"]]`)
445	MigrateBatch(cross(cur), "l1", "lifetime", `[["`+u+`","grin","1000"]]`)
446	MigrateBatch(cross(cur), "l2", "lifetime", `[["`+u+`","grin","1000"]]`)
447	if OwnedTools(u) != `["starter-twig","twig"]` || !HasAchievement(u, "blocks_1") || HasAchievement(u, "made-up") || getU(&miners, u) != 1000 {
448		t.Errorf("tools=%s medals=%s miners=%d", OwnedTools(u), GetMedals(u), getU(&miners, u))
449	}
450	FinalizeMigration(cross(cur))
451	mustAbort(t, "after finalize", revive(func() { MigrateBatch(cross(cur), "b2", "balance", rows) }), "finalized")
452	if !strings.Contains(MigrationStatus(), `"finalized":true`) {
453		t.Errorf("status %s", MigrationStatus())
454	}
455}
456
457// ── Misc fixes ───────────────────────────────────────────────────────────────
458
459func TestMisc_BuyToolLifeLeaderboardJsonRender(cur realm, t *testing.T) {
460	setup()
461	p := ta("misc-p")
462	give(p, "grin", 1000)
463	testing.SetRealm(testing.NewUserRealm(p))
464	BuyTool(cross(cur), "starter-twig")
465	for _, c := range toolCosts["twig"] {
466		n, _ := strconv.ParseUint(c[1], 10, 64)
467		give(p, c[0], n)
468	}
469	BuyTool(cross(cur), "twig")
470	if toolMaxLife["twig"] > 0 && getU(&toolLife, p.String()+":twig") != effectiveMaxLife(p.String(), "twig") {
471		t.Errorf("BuyTool did not set toolLife: %s", ToolLife(p.String()))
472	}
473	mustAbort(t, "buy twice", revive(func() { BuyTool(cross(cur), "twig") }), "already own")
474
475	setU(&miners, ta("lb-small").String(), 5)
476	setU(&miners, ta("lb-big").String(), 5_000_000)
477	lb := AllMiners(200, 0)
478	if !strings.HasPrefix(lb, `[{"address":"`+ta("lb-big").String()) {
479		t.Errorf("leaderboard not sorted by score: %s", lb[:120])
480	}
481
482	g := ta("misc-guild")
483	testing.SetRealm(testing.NewUserRealm(g))
484	gid := CreateGuild(cross(cur), `Evil","leader":"x`)
485	info := GuildInfo(gid)
486	if strings.Contains(info, `"leader":"x"`) {
487		t.Errorf("guild JSON injection: %s", info)
488	}
489	if !strings.Contains(Render(""), "game29") {
490		t.Errorf("Render: %s", Render(""))
491	}
492}
493
494// ── PlayerXp ─────────────────────────────────────────────────────────────────
495
496func TestPlayerXp_AccumulatesOnClaim(cur realm, t *testing.T) {
497	setup()
498	p := ta("xp-player")
499
500	// Baseline: fresh player has 0 XP.
501	if got := PlayerXp(p.String()); got != "0" {
502		t.Errorf("initial XP want 0 got %s", got)
503	}
504
505	// Claim 10 grin (rarity 0 → 3 XP each) = 30 XP.
506	payload := `[["grin","10"]]`
507	n, e, s := voucher(t, "claim", p, payload)
508	testing.SetRealm(testing.NewUserRealm(p))
509	ClaimAndMint(cross(cur), payload, n, e, s)
510
511	wantXp := uint64(10 * 3) // 10 coins × 3 XP (dust rarity)
512	gotXp := getU(&playerXp, p.String())
513	if gotXp != wantXp {
514		t.Errorf("after grin claim: want XP %d got %d (PlayerXp=%s)", wantXp, gotXp, PlayerXp(p.String()))
515	}
516
517	// PlayerXp query must return the same value as a decimal string.
518	if PlayerXp(p.String()) != strconv.FormatUint(wantXp, 10) {
519		t.Errorf("PlayerXp query mismatch: want %d got %s", wantXp, PlayerXp(p.String()))
520	}
521
522	// Upgrade to stone tool (maxRarity=2) so the second claim can mine music.
523	// Chain: hands → starter-twig → twig → wood → stone.
524	testing.SetRealm(testing.NewUserRealm(p))
525	for _, cost := range toolCosts["starter-twig"] {
526		n, _ := strconv.ParseUint(cost[1], 10, 64)
527		give(p, cost[0], n)
528	}
529	BuyTool(cross(cur), "starter-twig")
530	for _, cost := range toolCosts["twig"] {
531		n, _ := strconv.ParseUint(cost[1], 10, 64)
532		give(p, cost[0], n)
533	}
534	BuyTool(cross(cur), "twig")
535	for _, cost := range toolCosts["wood"] {
536		n, _ := strconv.ParseUint(cost[1], 10, 64)
537		give(p, cost[0], n)
538	}
539	BuyTool(cross(cur), "wood")
540	for _, cost := range toolCosts["stone"] {
541		n, _ := strconv.ParseUint(cost[1], 10, 64)
542		give(p, cost[0], n)
543	}
544	BuyTool(cross(cur), "stone")
545
546	// Second claim: add a higher-rarity coin. music is rarity 2 → 20 XP each.
547	testing.SkipHeights(13) // skip cooldown
548	payload2 := `[["music","5"]]`
549	n2, e2, s2 := voucher(t, "claim", p, payload2)
550	testing.SetRealm(testing.NewUserRealm(p))
551	ClaimAndMint(cross(cur), payload2, n2, e2, s2)
552
553	wantXp2 := wantXp + uint64(5*20) // +100 XP
554	gotXp2 := getU(&playerXp, p.String())
555	if gotXp2 != wantXp2 {
556		t.Errorf("after music claim: want XP %d got %d", wantXp2, gotXp2)
557	}
558
559	// XP accumulates across players independently.
560	q := ta("xp-q")
561	if PlayerXp(q.String()) != "0" {
562		t.Errorf("unrelated player should have 0 XP, got %s", PlayerXp(q.String()))
563	}
564}