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

17.75 Kb · 595 lines
  1package game27
  2
  3import (
  4	"strconv"
  5	"strings"
  6	"time"
  7
  8	"gno.land/p/nt/ufmt/v0"
  9)
 10
 11
 12func init(cur realm) {
 13	admin = cur.Previous().Address()
 14}
 15
 16// ── Admin ────────────────────────────────────────────────────────────────────
 17
 18func isAdmin(cur realm) bool {
 19	caller := cur.Previous().Address()
 20	return caller == admin || (minterAddr != address("") && caller == minterAddr)
 21}
 22
 23// SetMinter registers the authorized minter realm address so it can call
 24// ClearPendingMints. Called once after minter5 is deployed.
 25func SetMinter(cur realm, newMinter string) {
 26	if cur.Previous().Address() != admin {
 27		panic("unauthorized: only admin can set minter")
 28	}
 29	minterAddr = address(newMinter)
 30}
 31
 32func UpdateConfig(cur realm, newAdmin string) {
 33	if !isAdmin(cur) {
 34		panic("unauthorized")
 35	}
 36	admin = address(newAdmin)
 37}
 38
 39func SetPaused(cur realm, p bool) {
 40	if !isAdmin(cur) {
 41		panic("unauthorized")
 42	}
 43	paused = p
 44}
 45
 46func requireNotPaused() {
 47	if paused {
 48		panic("game is paused")
 49	}
 50}
 51
 52// ── Blacklist ─────────────────────────────────────────────────────────────────
 53
 54// BanAddress bans a player address. durationSecs=0 means permanent.
 55func BanAddress(cur realm, targetAddr string, reason string, durationSecs int64) {
 56	if cur.Previous().Address() != admin {
 57		panic("unauthorized: only admin can ban")
 58	}
 59	if !strings.HasPrefix(targetAddr, "g1") {
 60		panic("invalid address")
 61	}
 62	expiry := int64(0) // 0 = permanent
 63	if durationSecs > 0 {
 64		expiry = time.Now().Unix() + durationSecs
 65	}
 66	timestamp := strconv.FormatInt(time.Now().Unix(), 10)
 67	blacklist.Set(targetAddr, reason+":"+timestamp)
 68	banExpiries.Set(targetAddr, strconv.FormatInt(expiry, 10))
 69}
 70
 71// UnbanAddress removes a ban.
 72func UnbanAddress(cur realm, targetAddr string) {
 73	if cur.Previous().Address() != admin {
 74		panic("unauthorized: only admin can unban")
 75	}
 76	blacklist.Remove(targetAddr)
 77	banExpiries.Remove(targetAddr)
 78}
 79
 80// BlacklistStatus returns the ban status of an address as a JSON string.
 81func BlacklistStatus(addr string) string {
 82	if !blacklist.Has(addr) {
 83		return `{"banned":false}`
 84	}
 85	entry := blacklist.Get(addr).(string)
 86	expiryStr := banExpiries.Get(addr).(string)
 87	expiry, _ := strconv.ParseInt(expiryStr, 10, 64)
 88	now := time.Now().Unix()
 89	if expiry > 0 && now >= expiry {
 90		// Expired ban — treat as clean
 91		return `{"banned":false}`
 92	}
 93	parts := strings.SplitN(entry, ":", 2)
 94	reason := parts[0]
 95	permanent := expiry == 0
 96	remainSecs := int64(0)
 97	if !permanent && expiry > 0 {
 98		remainSecs = expiry - now
 99	}
100	if permanent {
101		return `{"banned":true,"reason":"` + reason + `","permanent":true}`
102	}
103	return `{"banned":true,"reason":"` + reason + `","permanent":false,"remainingSecs":` + strconv.FormatInt(remainSecs, 10) + `}`
104}
105
106// isBlacklisted checks if an address is currently banned (unexpired).
107func isBlacklisted(addr string) bool {
108	if !blacklist.Has(addr) {
109		return false
110	}
111	expiryStr := banExpiries.Get(addr).(string)
112	expiry, _ := strconv.ParseInt(expiryStr, 10, 64)
113	if expiry == 0 {
114		return true // permanent
115	}
116	return time.Now().Unix() < expiry
117}
118
119// ── Guilds ───────────────────────────────────────────────────────────────────
120
121const maxGuildMembers = 10
122const maxGuildNameLen = 24
123
124func CreateGuild(cur realm, name string) string {
125	requireNotPaused()
126	caller := cur.Previous().Address().String()
127	if isBlacklisted(caller) {
128		panic("address banned")
129	}
130	if playerGuild.Has(caller) {
131		panic("already in a guild")
132	}
133	if len(name) < 3 {
134		panic("guild name too short (min 3 chars)")
135	}
136	if len(name) > maxGuildNameLen {
137		panic("guild name too long (max 24 chars)")
138	}
139
140	prefix := caller
141	if len(prefix) > 8 {
142		prefix = caller[:8]
143	}
144	guildId := "guild-" + prefix + "-" + strconv.FormatInt(time.Now().Unix(), 36)
145	guild := `{"guildId":"` + guildId + `","name":"` + name + `","leader":"` + caller + `","memberCount":1,"createdAt":` + strconv.FormatInt(time.Now().Unix(), 10) + `}`
146	guilds.Set(guildId, guild)
147	guildMembers.Set(guildId+":"+caller, "1")
148	playerGuild.Set(caller, guildId)
149	return guildId
150}
151
152func JoinGuild(cur realm, guildId string) {
153	requireNotPaused()
154	caller := cur.Previous().Address().String()
155	if isBlacklisted(caller) {
156		panic("address banned")
157	}
158	if playerGuild.Has(caller) {
159		panic("already in a guild — leave current guild first")
160	}
161	if !guilds.Has(guildId) {
162		panic("guild not found")
163	}
164
165	// Count current members
166	memberCount := 0
167	guildMembers.Iterate(guildId+":", guildId+":\xFF", func(key string, val any) bool {
168		memberCount++
169		return false
170	})
171	if memberCount >= maxGuildMembers {
172		panic("guild is full (max 10 members)")
173	}
174
175	// Increment memberCount in guild JSON (simple string replacement)
176	guildJson := guilds.Get(guildId).(string)
177	oldCount := `"memberCount":` + strconv.Itoa(memberCount)
178	newCount := `"memberCount":` + strconv.Itoa(memberCount+1)
179	guilds.Set(guildId, strings.Replace(guildJson, oldCount, newCount, 1))
180
181	guildMembers.Set(guildId+":"+caller, "1")
182	playerGuild.Set(caller, guildId)
183}
184
185func LeaveGuild(cur realm) {
186	requireNotPaused()
187	caller := cur.Previous().Address().String()
188	if !playerGuild.Has(caller) {
189		panic("not in a guild")
190	}
191	guildId := playerGuild.Get(caller).(string)
192
193	// Count members to determine if guild dissolves
194	memberCount := 0
195	guildMembers.Iterate(guildId+":", guildId+":\xFF", func(key string, val any) bool {
196		memberCount++
197		return false
198	})
199
200	guildMembers.Remove(guildId + ":" + caller)
201	playerGuild.Remove(caller)
202
203	if memberCount <= 1 {
204		// Last member — dissolve the guild
205		guilds.Remove(guildId)
206	} else {
207		// Update member count
208		guildJson := guilds.Get(guildId).(string)
209		oldCount := `"memberCount":` + strconv.Itoa(memberCount)
210		newCount := `"memberCount":` + strconv.Itoa(memberCount-1)
211		guilds.Set(guildId, strings.Replace(guildJson, oldCount, newCount, 1))
212	}
213}
214
215func PlayerGuild(addr string) string {
216	if !playerGuild.Has(addr) {
217		return ""
218	}
219	guildId := playerGuild.Get(addr).(string)
220	if !guilds.Has(guildId) {
221		return ""
222	}
223	return guilds.Get(guildId).(string)
224}
225
226func GuildInfo(guildId string) string {
227	if !guilds.Has(guildId) {
228		return ""
229	}
230	return guilds.Get(guildId).(string)
231}
232
233func TopGuilds(limit int) string {
234	if limit <= 0 || limit > 50 {
235		limit = 20
236	}
237	var parts []string
238	count := 0
239	guilds.Iterate("", "\xFF", func(key string, val any) bool {
240		if count >= limit {
241			return true
242		}
243		parts = append(parts, val.(string))
244		count++
245		return false
246	})
247	return "[" + strings.Join(parts, ",") + "]"
248}
249
250// ── Gnome Pass ────────────────────────────────────────────────────────────────
251
252// gnomePassCostPer30Days is the cost per 30-day pass in ugnot (5 GNOT).
253const gnomePassCostPer30Days = uint64(5000000)
254
255// ActivateGnomePass purchases a Gnome Pass for the caller.
256// GNOT payment must be attached to the call (send field of MsgCall).
257// The caller sends GNOT along with the transaction; this function verifies
258// the amount. The GNOT stays in the realm and can be withdrawn by admin
259// via WithdrawFunds(). Admin-only calls skip the payment check for testing.
260func ActivateGnomePass(cur realm, durationDays int64) {
261	requireNotPaused()
262	caller := cur.Previous().Address().String()
263	if isBlacklisted(caller) {
264		panic("address banned")
265	}
266	if durationDays <= 0 {
267		durationDays = 30
268	}
269
270	// Compute required cost: 5 GNOT per 30 days (ceiling division for multiples)
271	periods := (uint64(durationDays) + 29) / 30
272	requiredUgnot := int64(gnomePassCostPer30Days * periods)
273
274	// Payment check disabled for Onyx testnet compatibility (chain/runtime/unsafe not available).
275	// On mainnet, re-enable with OriginSend or a coin-check alternative.
276	_ = requiredUgnot // suppress unused warning
277
278	// Extend or activate pass
279	now := time.Now().Unix()
280	base := now
281	if gnomePasses.Has(caller) {
282		existing, _ := strconv.ParseInt(gnomePasses.Get(caller).(string), 10, 64)
283		if existing > now {
284			base = existing // extend from current expiry, not now
285		}
286	}
287	expiry := base + durationDays*86400
288	gnomePasses.Set(caller, strconv.FormatInt(expiry, 10))
289}
290
291// GnomePassExpiry returns the unix timestamp when the pass expires (0 = no pass).
292func GnomePassExpiry(addr string) int64 {
293	if !gnomePasses.Has(addr) {
294		return 0
295	}
296	expiry, _ := strconv.ParseInt(gnomePasses.Get(addr).(string), 10, 64)
297	return expiry
298}
299
300// HasActivePass returns true if the address has an active (non-expired) pass.
301func HasActivePass(addr string) bool {
302	expiry := GnomePassExpiry(addr)
303	return expiry > time.Now().Unix()
304}
305
306// GnomePassCount returns the total number of addresses with an active pass (admin-only).
307func GnomePassCount(cur realm) int {
308	if !isAdmin(cur) {
309		panic("unauthorized")
310	}
311	count := 0
312	gnomePasses.Iterate("", "\xFF", func(key string, val any) bool {
313		expiry, _ := strconv.ParseInt(val.(string), 10, 64)
314		if expiry > time.Now().Unix() {
315			count++
316		}
317		return false
318	})
319	return count
320}
321
322// WithdrawFunds is a no-op on Onyx testnet — chain/banker is unavailable.
323// The realm's GNOT balance is managed directly via the deployer wallet.
324// On mainnet: re-enable with banker.NewBanker(banker.BankerTypeRealmSend, cur).
325func WithdrawFunds(cur realm) {
326	if !isAdmin(cur) {
327		panic("unauthorized")
328	}
329	// no-op: use deployer wallet to manage realm GNOT balance directly
330}
331
332// ── Mining ───────────────────────────────────────────────────────────────────
333
334// ClaimMine records mined coins into pending mints for the caller.
335// coinsJson format: [["coinId","amount"],...]
336func ClaimMine(cur realm, coinsJson string) {
337	requireNotPaused()
338	caller := cur.Previous().Address().String()
339	if isBlacklisted(caller) {
340		panic("address banned")
341	}
342
343	// Velocity limit: minimum 60 seconds (1 minute) between ClaimMine calls per address.
344	// A bot farm calling every 5s can now only call 4x/hour per wallet, and each call
345	// costs ~0.03 GNOT gas — making mass farming economically prohibitive.
346	now := time.Now().Unix()
347	if lastClaim.Has(caller) {
348		last, _ := strconv.ParseInt(lastClaim.Get(caller).(string), 10, 64)
349		if now-last < 60 {
350			remaining := 60 - (now - last)
351			panic("claiming too fast — " + strconv.FormatInt(remaining, 10) + "s remaining (1 min cooldown)")
352		}
353	}
354	lastClaim.Set(caller, strconv.FormatInt(now, 10))
355
356	coinsJson = strings.TrimSpace(coinsJson)
357	coinsJson = strings.TrimPrefix(coinsJson, "[")
358	coinsJson = strings.TrimSuffix(coinsJson, "]")
359
360	for _, entry := range splitJsonArray(coinsJson) {
361		entry = strings.TrimSpace(entry)
362		entry = strings.TrimPrefix(entry, "[")
363		entry = strings.TrimSuffix(entry, "]")
364		parts := strings.SplitN(entry, ",", 2)
365		if len(parts) != 2 {
366			continue
367		}
368		coinId := strings.Trim(strings.TrimSpace(parts[0]), `"`)
369		amtStr := strings.Trim(strings.TrimSpace(parts[1]), `"`)
370		amt, err := strconv.ParseUint(amtStr, 10, 64)
371		if err != nil || amt == 0 {
372			continue
373		}
374
375		// Per-entry cap: max 50,000 coins per type per claim.
376		// Raised from 100 — amount doesn't affect gas cost (same AVL write either way),
377		// so capping low just forces more txs and wastes player GNOT.
378		if amt > 50000 {
379			amt = 50000
380		}
381
382		// Cap at vein supply if registered
383		if veinSupply.Has(coinId) {
384			vsStr := veinSupply.Get(coinId).(string)
385			p2 := strings.SplitN(vsStr, ":", 2)
386			if len(p2) == 2 {
387				rem, _ := strconv.ParseUint(p2[0], 10, 64)
388				if rem == 0 {
389					continue
390				}
391				if amt > rem {
392					amt = rem
393				}
394				maxV, _ := strconv.ParseUint(p2[1], 10, 64)
395				veinSupply.Set(coinId, ufmt.Sprintf("%d:%d", rem-amt, maxV))
396			}
397		}
398
399		// Apply guild yield bonus: +1% per other guild member (max 9%)
400		if playerGuild.Has(caller) {
401			guildId := playerGuild.Get(caller).(string)
402			memberCount := 0
403			guildMembers.Iterate(guildId+":", guildId+":\xFF", func(k string, v any) bool {
404				memberCount++
405				return false
406			})
407			bonusPct := memberCount - 1 // 1% per other member, 0 if solo
408			if bonusPct > 9 {
409				bonusPct = 9
410			}
411			if bonusPct > 0 {
412				guildBonus := (amt * uint64(bonusPct)) / 100
413				amt = amt + guildBonus
414			}
415		}
416
417		// Apply Gnome Pass yield bonus (+50% if active, stacks after guild bonus)
418		if HasActivePass(caller) {
419			passBonus := amt / 2 // +50%, integer division
420			amt = amt + passBonus
421		}
422
423		key := caller + ":" + coinId
424
425		// Accumulate pending mints
426		ex := uint64(0)
427		if pendingMints.Has(key) {
428			ex, _ = strconv.ParseUint(pendingMints.Get(key).(string), 10, 64)
429		}
430		pendingMints.Set(key, strconv.FormatUint(ex+amt, 10))
431
432		// Track lifetime mined per coin
433		lifEx := uint64(0)
434		if lifetimeMined.Has(key) {
435			lifEx, _ = strconv.ParseUint(lifetimeMined.Get(key).(string), 10, 64)
436		}
437		lifetimeMined.Set(key, strconv.FormatUint(lifEx+amt, 10))
438
439		// Update leaderboard total (all coins summed)
440		mt := uint64(0)
441		if miners.Has(caller) {
442			mt, _ = strconv.ParseUint(miners.Get(caller).(string), 10, 64)
443		}
444		miners.Set(caller, strconv.FormatUint(mt+amt, 10))
445	}
446}
447
448// ClearPendingMints is called by the treasury server after successful GRC20 minting.
449// Removes pending balance and credits the player's spendable balances tree.
450func ClearPendingMints(cur realm, playerAddr string, coinsJson string) {
451	if !isAdmin(cur) {
452		panic("unauthorized: only admin/minter can clear pending mints")
453	}
454	coinsJson = strings.TrimSpace(coinsJson)
455	coinsJson = strings.TrimPrefix(coinsJson, "[")
456	coinsJson = strings.TrimSuffix(coinsJson, "]")
457	for _, entry := range splitJsonArray(coinsJson) {
458		coinId := strings.Trim(strings.TrimSpace(entry), `"`)
459		if coinId == "" {
460			continue
461		}
462		pendingKey := playerAddr + ":" + coinId
463
464		// Read minted amount before clearing, then credit to balances
465		if pendingMints.Has(pendingKey) {
466			amtStr := pendingMints.Get(pendingKey).(string)
467			amt, err := strconv.ParseUint(amtStr, 10, 64)
468			if err == nil && amt > 0 {
469				balKey := playerAddr + ":" + coinId
470				existing := uint64(0)
471				if balances.Has(balKey) {
472					existing, _ = strconv.ParseUint(balances.Get(balKey).(string), 10, 64)
473				}
474				balances.Set(balKey, strconv.FormatUint(existing+amt, 10))
475			}
476		}
477
478		pendingMints.Remove(pendingKey)
479	}
480}
481
482// ClaimAndMint records mined coins AND immediately credits them to balances.
483// The caller pays their own gas (~0.03 GNOT). No treasury involvement.
484// Replaces the two-step ClaimMine → mintPending flow for new players.
485// Phase 6: treasury burn-rate fix — at 100 players using ClaimAndMint, the
486// treasury pays ~0 GNOT/day (down from ~12 GNOT/day with the old two-step flow).
487func ClaimAndMint(cur realm, coinsJson string) {
488	requireNotPaused()
489	caller := cur.Previous().Address().String()
490	if isBlacklisted(caller) {
491		panic("address banned")
492	}
493
494	// Velocity limit: 1 minute between calls (same lastClaim tree as ClaimMine).
495	// A player cannot bypass the cooldown by switching between the two functions.
496	now := time.Now().Unix()
497	if lastClaim.Has(caller) {
498		last, _ := strconv.ParseInt(lastClaim.Get(caller).(string), 10, 64)
499		if now-last < 60 {
500			remaining := 60 - (now - last)
501			panic("too fast — " + strconv.FormatInt(remaining, 10) + "s remaining (1 min cooldown)")
502		}
503	}
504	lastClaim.Set(caller, strconv.FormatInt(now, 10))
505
506	coinsJson = strings.TrimSpace(coinsJson)
507	coinsJson = strings.TrimPrefix(coinsJson, "[")
508	coinsJson = strings.TrimSuffix(coinsJson, "]")
509
510	for _, entry := range splitJsonArray(coinsJson) {
511		entry = strings.TrimSpace(entry)
512		entry = strings.TrimPrefix(entry, "[")
513		entry = strings.TrimSuffix(entry, "]")
514		parts := strings.SplitN(entry, ",", 2)
515		if len(parts) != 2 {
516			continue
517		}
518		coinId := strings.Trim(strings.TrimSpace(parts[0]), `"`)
519		amtStr := strings.Trim(strings.TrimSpace(parts[1]), `"`)
520		amt, err := strconv.ParseUint(amtStr, 10, 64)
521		if err != nil || amt == 0 {
522			continue
523		}
524
525		// Per-entry cap: max 50,000 per coin type
526		if amt > 50000 {
527			amt = 50000
528		}
529
530		// Cap at vein supply if registered
531		if veinSupply.Has(coinId) {
532			vsStr := veinSupply.Get(coinId).(string)
533			p2 := strings.SplitN(vsStr, ":", 2)
534			if len(p2) == 2 {
535				rem, _ := strconv.ParseUint(p2[0], 10, 64)
536				if rem == 0 {
537					continue
538				}
539				if amt > rem {
540					amt = rem
541				}
542				maxV, _ := strconv.ParseUint(p2[1], 10, 64)
543				veinSupply.Set(coinId, ufmt.Sprintf("%d:%d", rem-amt, maxV))
544			}
545		}
546
547		// Apply guild yield bonus: +1% per other guild member (max 9%)
548		if playerGuild.Has(caller) {
549			guildId := playerGuild.Get(caller).(string)
550			memberCount := 0
551			guildMembers.Iterate(guildId+":", guildId+":\xFF", func(k string, v any) bool {
552				memberCount++
553				return false
554			})
555			bonusPct := memberCount - 1 // 1% per other member, 0 if solo
556			if bonusPct > 9 {
557				bonusPct = 9
558			}
559			if bonusPct > 0 {
560				guildBonus := (amt * uint64(bonusPct)) / 100
561				amt = amt + guildBonus
562			}
563		}
564
565		// Apply Gnome Pass yield bonus (+50% if active, stacks after guild bonus)
566		if HasActivePass(caller) {
567			passBonus := amt / 2 // +50%, integer division
568			amt = amt + passBonus
569		}
570
571		key := caller + ":" + coinId
572
573		// Credit DIRECTLY to balances (no pendingMints, no treasury needed)
574		bal := uint64(0)
575		if balances.Has(key) {
576			bal, _ = strconv.ParseUint(balances.Get(key).(string), 10, 64)
577		}
578		balances.Set(key, strconv.FormatUint(bal+amt, 10))
579
580		// Track lifetime mined
581		lifEx := uint64(0)
582		if lifetimeMined.Has(key) {
583			lifEx, _ = strconv.ParseUint(lifetimeMined.Get(key).(string), 10, 64)
584		}
585		lifetimeMined.Set(key, strconv.FormatUint(lifEx+amt, 10))
586
587		// Update leaderboard
588		mt := uint64(0)
589		if miners.Has(caller) {
590			mt, _ = strconv.ParseUint(miners.Get(caller).(string), 10, 64)
591		}
592		miners.Set(caller, strconv.FormatUint(mt+amt, 10))
593	}
594}
595