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}