game_mining.gno
16.34 Kb · 496 lines
1package game31
2
3import (
4 "crypto/ed25519"
5 "encoding/hex"
6 "sort"
7 "strconv"
8 "strings"
9 "time"
10
11 "chain/runtime"
12
13 "gno.land/p/nt/ufmt/v0"
14)
15
16// ── Vouchers ─────────────────────────────────────────────────────────────────
17//
18// The server's claim signer (ed25519) signs (v2 format):
19//
20// gnomoji-voucher-v2|<chainId>|<kind>|<player>|<nonce>|<expiry>|<compactPayload>
21//
22// compactPayload for kind="claim": coinId:amount pairs sorted alphabetically by
23// coinId, comma-joined. e.g. "cool:7,grin:100,joy:50"
24// compactPayload for kind="ach": achievement IDs sorted alphabetically,
25// comma-joined. e.g. "blocks_1,g.grin.10"
26//
27// The realm reconstructs compactPayload from the coinsJson/idsJson argument
28// before calling ed25519.Verify, then uses coinsJson/idsJson for actual logic.
29//
30// v2 changes from v1: realmPkgPath removed; payload is now compact (not raw JSON).
31// Replay protection is retained via per-player on-chain nonces (claimNonces) and
32// chain ID — a voucher cannot be replayed on game30 (different nonce tree state).
33//
34// nonce must be strictly greater than the player's last nonce (one shared
35// sequence for all kinds), expiry is unix seconds and at most voucherMaxTTL
36// in the future. A voucher is single-use and bound to one player and chain.
37
38// VoucherMessage builds the canonical message that the server signs and the
39// realm verifies. Format: gnomoji-voucher-v2|chainId|kind|player|nonce|expiry|compactPayload
40//
41// v2 changes from v1: realmPkgPath removed; payload is now compact (not raw JSON).
42// For kind="claim": compactPayload = coinId:amount pairs sorted by coinId, comma-joined.
43// e.g. [["grin","100"],["joy","50"]] → "grin:100,joy:50"
44// For kind="ach": compactPayload = achievement IDs sorted, comma-joined.
45// e.g. ["blocks_1","g.grin.10"] → "blocks_1,g.grin.10"
46func VoucherMessage(kind, player string, nonce, expiry int64, payload string) string {
47 return "gnomoji-voucher-v2|" + runtime.ChainID() + "|" + kind + "|" + player + "|" +
48 strconv.FormatInt(nonce, 10) + "|" + strconv.FormatInt(expiry, 10) + "|" + payload
49}
50
51// compactClaimPayload builds the canonical compact payload for a "claim" voucher
52// from a coinsJson string: [["coinId","amount"],...].
53// Output: "coinId:amount" pairs sorted alphabetically by coinId, comma-joined.
54// Must produce the same output as the TypeScript claimVoucherPayload() function
55// in src/lib/game/claim-voucher.ts.
56func compactClaimPayload(coinsJson string) string {
57 rows := parseStringRows(coinsJson)
58 // Collect unique coinId:amount pairs (duplicate coinIds summed).
59 sums := map[string]uint64{}
60 var order []string
61 for _, row := range rows {
62 if len(row) != 2 {
63 continue
64 }
65 amt, _ := strconv.ParseUint(row[1], 10, 64)
66 if _, seen := sums[row[0]]; !seen {
67 order = append(order, row[0])
68 }
69 sums[row[0]] += amt
70 }
71 // Sort alphabetically by coinId.
72 sort.Strings(order)
73 parts := make([]string, 0, len(order))
74 for _, coinId := range order {
75 parts = append(parts, coinId+":"+strconv.FormatUint(sums[coinId], 10))
76 }
77 return strings.Join(parts, ",")
78}
79
80// compactAchPayload builds the canonical compact payload for an "ach" voucher
81// from an idsJson string: ["achId",...].
82// Output: achievement IDs sorted alphabetically, comma-joined.
83func compactAchPayload(idsJson string) string {
84 rows := parseStringList(idsJson)
85 sort.Strings(rows)
86 return strings.Join(rows, ",")
87}
88
89// LastNonce returns the last voucher nonce used by addr (0 if none).
90func LastNonce(addr string) int64 {
91 if !claimNonces.Has(addr) {
92 return 0
93 }
94 n, _ := strconv.ParseInt(claimNonces.Get(addr).(string), 10, 64)
95 return n
96}
97
98func useVoucher(kind, player string, nonce, expiry int64, payload, sigHex string) {
99 if len(claimSignerPubKey) != 32 {
100 panic("claims are disabled: no claim signer set")
101 }
102 now := time.Now().Unix()
103 if expiry < now {
104 panic("voucher expired")
105 }
106 if expiry > now+voucherMaxTTL {
107 panic("voucher expiry too far in the future")
108 }
109 // FIX 1D: cache lastN to avoid calling LastNonce (2 AVL reads) twice.
110 lastN := LastNonce(player)
111 if nonce <= lastN {
112 panic("voucher already used (nonce " + strconv.FormatInt(nonce, 10) + " ≤ last " + strconv.FormatInt(lastN, 10) + ")")
113 }
114 sig, err := hex.DecodeString(sigHex)
115 if err != nil || len(sig) != 64 {
116 panic("invalid voucher signature encoding")
117 }
118 compact := payload
119 if kind == "claim" {
120 compact = compactClaimPayload(payload)
121 } else if kind == "ach" {
122 compact = compactAchPayload(payload)
123 }
124 if !ed25519.Verify(claimSignerPubKey, []byte(VoucherMessage(kind, player, nonce, expiry, compact)), sig) {
125 panic("invalid voucher signature")
126 }
127 claimNonces.Set(player, strconv.FormatInt(nonce, 10))
128}
129
130// ── Claim-rate helpers ────────────────────────────────────────────────────────
131
132// bestOwnedTool returns the highest-tier tool the caller owns that still has
133// durability (or is indestructible). "hands" if none.
134func bestOwnedTool(callerAddr string) string {
135 if !ownedTools.Has(callerAddr) {
136 return "hands"
137 }
138 ownedSet := map[string]bool{}
139 for _, t := range strings.Split(ownedTools.Get(callerAddr).(string), ",") {
140 if t = strings.TrimSpace(t); t != "" {
141 ownedSet[t] = true
142 }
143 }
144 for i := len(toolOrder) - 1; i >= 0; i-- {
145 tid := toolOrder[i]
146 if !ownedSet[tid] {
147 continue
148 }
149 maxLife, hasMax := toolMaxLife[tid]
150 if !hasMax || maxLife == 0 {
151 return tid
152 }
153 lifeKey := callerAddr + ":" + tid
154 if !toolLife.Has(lifeKey) || getU(&toolLife, lifeKey) > 0 {
155 return tid
156 }
157 }
158 return "hands"
159}
160
161func effectiveRateFor(tool string, r int) uint64 {
162 rates, ok := claimRatePerMin[tool]
163 if !ok {
164 rates = claimRatePerMin["hands"]
165 }
166 base := rates[r]
167 var eff uint64
168 if r == 0 {
169 eff = (base * skillDmgHeadroom * skillDustHeadroom) / 10000
170 } else {
171 eff = (base * skillDmgHeadroom) / 100
172 }
173 if eff == 0 {
174 eff = base
175 }
176 return eff
177}
178
179// loadPackedBuckets reads the packed claimBuckets entry for addr, or creates
180// a fresh one. Returns [7][2]uint64 where [r][0]=accruedMillis, [r][1]=lastTs.
181// Lazy-migrates from the old per-rarity key format (addr+":"+r) on first read
182// after an upgrade.
183func loadPackedBuckets(addr string, nowTs int64) [7][2]uint64 {
184 var buckets [7][2]uint64
185 // Set default initial values (will be overwritten if stored data exists).
186 for r := 0; r < 7; r++ {
187 buckets[r][0] = 0 // accruedMillis: start at zero, will refill on next bucketState call
188 buckets[r][1] = uint64(nowTs)
189 }
190
191 // Try new packed format first.
192 if claimBuckets.Has(addr) {
193 packed := claimBuckets.Get(addr).(string)
194 parts := strings.Split(packed, "|")
195 for r := 0; r < 7 && r < len(parts); r++ {
196 sub := strings.SplitN(parts[r], ":", 2)
197 if len(sub) == 2 {
198 buckets[r][0], _ = strconv.ParseUint(sub[0], 10, 64)
199 ts, _ := strconv.ParseInt(sub[1], 10, 64)
200 buckets[r][1] = uint64(ts)
201 }
202 }
203 return buckets
204 }
205
206 // Lazy migration: check for old per-rarity keys.
207 foundOld := false
208 for r := 0; r < 7; r++ {
209 oldKey := addr + ":" + strconv.Itoa(r)
210 if claimBuckets.Has(oldKey) {
211 foundOld = true
212 parts := strings.SplitN(claimBuckets.Get(oldKey).(string), ":", 2)
213 if len(parts) == 2 {
214 buckets[r][0], _ = strconv.ParseUint(parts[0], 10, 64)
215 ts, _ := strconv.ParseInt(parts[1], 10, 64)
216 buckets[r][1] = uint64(ts)
217 }
218 claimBuckets.Remove(oldKey)
219 }
220 }
221 if foundOld {
222 // Write the migrated packed entry immediately so future reads are fast.
223 claimBuckets.Set(addr, formatPackedBuckets(buckets))
224 }
225 return buckets
226}
227
228// formatPackedBuckets serializes [7][2]uint64 to "m0:t0|m1:t1|...|m6:t6".
229func formatPackedBuckets(buckets [7][2]uint64) string {
230 parts := make([]string, 7)
231 for r := 0; r < 7; r++ {
232 parts[r] = strconv.FormatUint(buckets[r][0], 10) + ":" + strconv.FormatInt(int64(buckets[r][1]), 10)
233 }
234 return strings.Join(parts, "|")
235}
236
237// bucketState returns (accruedMillis, capMillis) for rarity r at nowTs.
238// allBuckets is the result of loadPackedBuckets — pass it in to avoid
239// re-reading the AVL entry for each rarity check.
240func bucketState(allBuckets [7][2]uint64, r int, tool string, nowTs int64) (uint64, uint64) {
241 eff := effectiveRateFor(tool, r)
242 capMillis := eff * uint64(claimBucketSecs) * 1000 / 60
243 stored := allBuckets[r][0]
244 lastTs := int64(allBuckets[r][1])
245 // A new rarity bucket defaults to lastTs=nowTs, stored=0.
246 // Give it the initial grace allowance so first-ever claims aren't blocked.
247 if lastTs == nowTs && stored == 0 {
248 stored = eff * uint64(claimInitialGraceSec) * 1000 / 60
249 }
250 if elapsed := nowTs - lastTs; elapsed > 0 {
251 stored += uint64(elapsed) * eff * 1000 / 60
252 }
253 if stored > capMillis {
254 stored = capMillis
255 }
256 return stored, capMillis
257}
258
259// enforceClaimRate checks every rarity first, then deducts (no partial writes).
260// FIX 2A: reads claimBuckets once (packed format) instead of once per rarity.
261func enforceClaimRate(callerAddr string, byRarity [7]uint64, nowTs int64, tool string) {
262 maxR, ok := toolMaxRarity[tool]
263 if !ok {
264 maxR = 0
265 }
266 // Load all 7 rarity buckets in one AVL read (or lazy-migrate from old format).
267 allBuckets := loadPackedBuckets(callerAddr, nowTs)
268 var after [7]uint64
269 for r := 0; r < 7; r++ {
270 claimed := byRarity[r]
271 if claimed == 0 {
272 continue
273 }
274 if r > maxR {
275 panic(ufmt.Sprintf("rate limit: %s tool cannot mine rarity %d coins", tool, r))
276 }
277 stored, capMillis := bucketState(allBuckets, r, tool, nowTs)
278 if claimed*1000 > stored {
279 panic(ufmt.Sprintf("rate limit rarity %d: tried %d, allowed %d (refills at %d/min, max bank %d)",
280 r, claimed, stored/1000, effectiveRateFor(tool, r), capMillis/1000))
281 }
282 after[r] = stored - claimed*1000
283 }
284 // Compute new packed state and write once.
285 var newBuckets [7][2]uint64
286 for r := 0; r < 7; r++ {
287 if byRarity[r] > 0 {
288 newBuckets[r][0] = after[r]
289 newBuckets[r][1] = uint64(nowTs)
290 } else {
291 newBuckets[r][0] = allBuckets[r][0]
292 newBuckets[r][1] = allBuckets[r][1]
293 }
294 }
295 claimBuckets.Set(callerAddr, formatPackedBuckets(newBuckets))
296}
297
298// decrementToolLife wears the best tool by the number of non-dust coins.
299// FIX 1A: accepts pre-computed tool to avoid a second bestOwnedTool call.
300func decrementToolLife(callerAddr string, totalCoins uint64, tool string) {
301 if totalCoins == 0 {
302 return
303 }
304 maxLife, hasMax := toolMaxLife[tool]
305 if !hasMax || maxLife == 0 {
306 return
307 }
308 lifeKey := callerAddr + ":" + tool
309 current := effectiveMaxLife(callerAddr, tool)
310 if toolLife.Has(lifeKey) {
311 current = getU(&toolLife, lifeKey)
312 }
313 if totalCoins >= current {
314 setU(&toolLife, lifeKey, 0)
315 } else {
316 setU(&toolLife, lifeKey, current-totalCoins)
317 }
318}
319
320// parseCoinClaims parses [["coinId","amount"],...]. Unknown ids panic
321// (allowlist); duplicate ids are summed; at most 20 entries.
322func parseCoinClaims(coinsJson string) ([]string, map[string]uint64, [7]uint64) {
323 coinMap := map[string]uint64{}
324 var order []string
325 var byRarity [7]uint64
326 rows := parseStringRows(coinsJson)
327 if len(rows) == 0 {
328 panic("no coins to claim")
329 }
330 if len(rows) > 20 {
331 panic("too many coin entries (max 20)")
332 }
333 for _, row := range rows {
334 if len(row) != 2 {
335 panic("each claim entry must be [coinId, amount]")
336 }
337 rarity, known := coinRarity[row[0]]
338 if !known {
339 panic("unknown coin id: " + row[0])
340 }
341 amt, err := strconv.ParseUint(row[1], 10, 64)
342 if err != nil || amt == 0 || amt > 1000000 {
343 panic("invalid amount for " + row[0])
344 }
345 if _, seen := coinMap[row[0]]; !seen {
346 order = append(order, row[0])
347 }
348 coinMap[row[0]] += amt
349 byRarity[rarity] += amt
350 }
351 return order, coinMap, byRarity
352}
353
354func guildBonusPct(addr string) uint64 {
355 if !playerGuild.Has(addr) {
356 return 0
357 }
358 gid := playerGuild.Get(addr).(string)
359 n := 0
360 guildMembers.Iterate(gid+":", gid+";", func(string, any) bool {
361 n++
362 return false
363 })
364 if n <= 1 {
365 return 0
366 }
367 if n-1 > 9 {
368 return 9
369 }
370 return uint64(n - 1)
371}
372
373// creditMined credits a mined/reward amount to addr, capped by the vein, and
374// records lifetime total. Returns the amount credited.
375// FIX 1B: miners update removed — caller must addU(&miners, addr, total) after the loop.
376func creditMined(addr, coinId string, amt uint64) uint64 {
377 amt = takeFromVein(coinId, amt)
378 if amt == 0 {
379 return 0
380 }
381 creditBal(addr, coinId, amt)
382 addU(&lifetimeMined, addr+":"+coinId, amt)
383 return amt
384}
385
386// creditReward credits a reward (medal/achievement or any future event). It
387// still draws from the vein (so rewards can't exceed supply caps) but is
388// tracked in its own counter: it never touches lifetimeMined or the miners
389// leaderboard, so rewards can't unlock grind medals or inflate rankings.
390func creditReward(addr, coinId string, amt uint64) uint64 {
391 amt = takeFromVein(coinId, amt)
392 if amt == 0 {
393 return 0
394 }
395 creditBal(addr, coinId, amt)
396 addU(&rewarded, addr+":"+coinId, amt)
397 return amt
398}
399
400// ── Claims ───────────────────────────────────────────────────────────────────
401
402// ClaimAndMint credits mined coins to the caller's on-chain balance.
403// coinsJson: [["coinId","amount"],...]. Requires a server voucher
404// (see VoucherMessage) for exactly this coinsJson, plus the on-chain rate cap
405// for the caller's best tool, the coin allowlist and the vein caps.
406// Returns a JSON object of what was actually credited (after bonuses/veins).
407func ClaimAndMint(cur realm, coinsJson string, nonce int64, expiry int64, sigHex string) string {
408 caller := requirePlayer(cur)
409 now := time.Now().Unix()
410 // Voucher first (clear errors for forged/replayed vouchers); a failed tx
411 // reverts, so a valid voucher isn't burned by the cooldown below.
412 useVoucher("claim", caller, nonce, expiry, coinsJson, sigHex)
413 if lastClaim.Has(caller) {
414 last, _ := strconv.ParseInt(lastClaim.Get(caller).(string), 10, 64)
415 if now-last < claimCooldownSec {
416 panic("too fast — " + strconv.FormatInt(claimCooldownSec-(now-last), 10) + "s remaining (1 min cooldown)")
417 }
418 }
419 lastClaim.Set(caller, strconv.FormatInt(now, 10))
420
421 order, coinMap, byRarity := parseCoinClaims(coinsJson)
422
423 // FIX 1A: compute tool once and pass to both functions.
424 tool := bestOwnedTool(caller)
425 enforceClaimRate(caller, byRarity, now, tool)
426
427 nonDust := uint64(0)
428 for r := 1; r < 7; r++ {
429 nonDust += byRarity[r]
430 }
431 decrementToolLife(caller, nonDust, tool)
432
433 bonus := guildBonusPct(caller)
434 pass := HasActivePass(caller)
435 // rarityXpWeights maps rarity index (0=dust … 6=mythic) to XP per credited coin.
436 // Matches the client-side rarityXp() table in store/helpers.ts exactly.
437 rarityXpWeights := [7]uint64{3, 8, 20, 50, 120, 300, 800}
438 var out []string
439 sessionXp := uint64(0)
440 // FIX 1B: accumulate miners total outside the loop.
441 totalCredited := uint64(0)
442 for _, coinId := range order {
443 amt := coinMap[coinId]
444 if bonus > 0 {
445 amt += amt * bonus / 100
446 }
447 if pass {
448 amt += amt / 2
449 }
450 got := creditMined(caller, coinId, amt)
451 totalCredited += got
452 out = append(out, jsonStr(coinId)+":"+strconv.FormatUint(got, 10))
453 // Accumulate rarity-weighted XP for each credited coin.
454 r := coinRarity[coinId]
455 if r < 0 {
456 r = 0
457 } else if r > 6 {
458 r = 6
459 }
460 sessionXp += got * rarityXpWeights[r]
461 }
462 // FIX 1B: single miners write after loop instead of N writes inside loop.
463 if totalCredited > 0 {
464 addU(&miners, caller, totalCredited)
465 }
466 if sessionXp > 0 {
467 addU(&playerXp, caller, sessionXp)
468 }
469 return "{" + strings.Join(out, ",") + "}"
470}
471
472// ClaimMine (game27/28 two-step flow) is retired: pending mints are gone.
473func ClaimMine(cur realm, coinsJson string) {
474 panic("ClaimMine is retired in game29 — use ClaimAndMint with a server voucher")
475}
476
477// PendingMints is kept for client compatibility; game29 has no pending step.
478func PendingMints(playerAddr string) string { return `{"pending":[]}` }
479
480// ClaimAllowance returns the current per-rarity allowance for addr.
481func ClaimAllowance(playerAddr string) string {
482 tool := bestOwnedTool(playerAddr)
483 now := time.Now().Unix()
484 // Load packed buckets once for all 7 rarities.
485 allBuckets := loadPackedBuckets(playerAddr, now)
486 var parts []string
487 for r := 0; r < 7; r++ {
488 stored, _ := bucketState(allBuckets, r, tool, now)
489 maxR, ok := toolMaxRarity[tool]
490 if !ok || r > maxR {
491 stored = 0
492 }
493 parts = append(parts, `"r`+strconv.Itoa(r)+`":`+strconv.FormatUint(stored/1000, 10))
494 }
495 return "{" + strings.Join(parts, ",") + `,"tool":` + jsonStr(tool) + `,"nextNonce":` + strconv.FormatInt(LastNonce(playerAddr)+1, 10) + "}"
496}