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