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}