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.

game_mining.gno

16.34 Kb · 496 lines
  1package game29
  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}