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.gno

18.41 Kb · 586 lines
  1// GnoMoji — Main Game Realm
  2// gno.land/r/gnomoji/game
  3//
  4// On-chain game engine for GnoMoji on Gno.land (v1.5.0 / Onyx testnet API).
  5// Tracks mined coins, casino rounds, PvP lobbies, tools, skills, achievements.
  6//
  7// Token minting is handled by gno.land/r/gnomoji/minter (the treasury realm).
  8// GRC20 coin realms live at gno.land/r/gnomoji/coins/{coinId}.
  9
 10package game3
 11
 12import (
 13	"strconv"
 14	"strings"
 15	"time"
 16
 17	"gno.land/p/nt/avl/v0"
 18	"gno.land/p/nt/ufmt/v0"
 19)
 20
 21// ── State ────────────────────────────────────────────────────────────────────
 22
 23var (
 24	admin          address
 25	pendingMints   avl.Tree // addr+":"+coinId → amount string
 26	balances       avl.Tree // addr+":"+coinId → amount string
 27	veinSupply     avl.Tree // coinId → "remaining:max"
 28	casinoRounds   avl.Tree // roundId → JSON string
 29	pvpLobbies     avl.Tree // lobbyId → JSON string
 30	ownedTools     avl.Tree // addr → comma-separated tool IDs
 31	toolLife       avl.Tree // addr+":"+toolId → remaining life string
 32	lifetimeMined  avl.Tree // addr+":"+coinId → total mined string
 33	achievements   avl.Tree // addr+":"+achId → "1"
 34	dailyCheckins  avl.Tree // addr → unix seconds string
 35	skillRanks     avl.Tree // addr+":"+skillId → rank string
 36	miners         avl.Tree // addr → total mined all-time string
 37	lastClaim      avl.Tree // addr → unix timestamp of last ClaimMine (velocity limit)
 38	paused         bool
 39)
 40
 41func init(cur realm) {
 42	admin = cur.Previous().Address()
 43}
 44
 45// ── Admin ────────────────────────────────────────────────────────────────────
 46
 47func isAdmin(cur realm) bool {
 48	return cur.Previous().Address() == admin
 49}
 50
 51func UpdateConfig(cur realm, newAdmin string) {
 52	if !isAdmin(cur) {
 53		panic("unauthorized")
 54	}
 55	admin = address(newAdmin)
 56}
 57
 58func SetPaused(cur realm, p bool) {
 59	if !isAdmin(cur) {
 60		panic("unauthorized")
 61	}
 62	paused = p
 63}
 64
 65func requireNotPaused() {
 66	if paused {
 67		panic("game is paused")
 68	}
 69}
 70
 71// ── Mining ───────────────────────────────────────────────────────────────────
 72
 73// ClaimMine records mined coins into pending mints for the caller.
 74// coinsJson format: [["coinId","amount"],...]
 75func ClaimMine(cur realm, coinsJson string) {
 76	requireNotPaused()
 77	caller := cur.Previous().Address().String()
 78
 79	// Velocity limit: minimum 5 seconds between ClaimMine calls per address
 80	now := time.Now().Unix()
 81	if lastClaim.Has(caller) {
 82		last, _ := strconv.ParseInt(lastClaim.Get(caller).(string), 10, 64)
 83		if now-last < 5 {
 84			panic("claiming too fast — please wait 5 seconds between claims")
 85		}
 86	}
 87	lastClaim.Set(caller, strconv.FormatInt(now, 10))
 88
 89	coinsJson = strings.TrimSpace(coinsJson)
 90	coinsJson = strings.TrimPrefix(coinsJson, "[")
 91	coinsJson = strings.TrimSuffix(coinsJson, "]")
 92
 93	for _, entry := range splitJsonArray(coinsJson) {
 94		entry = strings.TrimSpace(entry)
 95		entry = strings.TrimPrefix(entry, "[")
 96		entry = strings.TrimSuffix(entry, "]")
 97		parts := strings.SplitN(entry, ",", 2)
 98		if len(parts) != 2 {
 99			continue
100		}
101		coinId := strings.Trim(strings.TrimSpace(parts[0]), `"`)
102		amtStr := strings.Trim(strings.TrimSpace(parts[1]), `"`)
103		amt, err := strconv.ParseUint(amtStr, 10, 64)
104		if err != nil || amt == 0 {
105			continue
106		}
107
108		// Cap at vein supply if registered
109		if veinSupply.Has(coinId) {
110			vsStr := veinSupply.Get(coinId).(string)
111			p2 := strings.SplitN(vsStr, ":", 2)
112			if len(p2) == 2 {
113				rem, _ := strconv.ParseUint(p2[0], 10, 64)
114				if rem == 0 {
115					continue
116				}
117				if amt > rem {
118					amt = rem
119				}
120				maxV, _ := strconv.ParseUint(p2[1], 10, 64)
121				veinSupply.Set(coinId, ufmt.Sprintf("%d:%d", rem-amt, maxV))
122			}
123		}
124
125		key := caller + ":" + coinId
126
127		// Accumulate pending mints
128		ex := uint64(0)
129		if pendingMints.Has(key) {
130			ex, _ = strconv.ParseUint(pendingMints.Get(key).(string), 10, 64)
131		}
132		pendingMints.Set(key, strconv.FormatUint(ex+amt, 10))
133
134		// Track lifetime mined per coin
135		lifEx := uint64(0)
136		if lifetimeMined.Has(key) {
137			lifEx, _ = strconv.ParseUint(lifetimeMined.Get(key).(string), 10, 64)
138		}
139		lifetimeMined.Set(key, strconv.FormatUint(lifEx+amt, 10))
140
141		// Update leaderboard total (all coins summed)
142		mt := uint64(0)
143		if miners.Has(caller) {
144			mt, _ = strconv.ParseUint(miners.Get(caller).(string), 10, 64)
145		}
146		miners.Set(caller, strconv.FormatUint(mt+amt, 10))
147	}
148}
149
150// ClearPendingMints is called by the treasury server after successful GRC20 minting.
151// Removes pending balance and credits the player's spendable balances tree.
152func ClearPendingMints(cur realm, playerAddr string, coinsJson string) {
153	if !isAdmin(cur) {
154		panic("unauthorized: only admin/minter can clear pending mints")
155	}
156	coinsJson = strings.TrimSpace(coinsJson)
157	coinsJson = strings.TrimPrefix(coinsJson, "[")
158	coinsJson = strings.TrimSuffix(coinsJson, "]")
159	for _, entry := range splitJsonArray(coinsJson) {
160		coinId := strings.Trim(strings.TrimSpace(entry), `"`)
161		if coinId == "" {
162			continue
163		}
164		pendingKey := playerAddr + ":" + coinId
165
166		// Read minted amount before clearing, then credit to balances
167		if pendingMints.Has(pendingKey) {
168			amtStr := pendingMints.Get(pendingKey).(string)
169			amt, err := strconv.ParseUint(amtStr, 10, 64)
170			if err == nil && amt > 0 {
171				balKey := playerAddr + ":" + coinId
172				existing := uint64(0)
173				if balances.Has(balKey) {
174					existing, _ = strconv.ParseUint(balances.Get(balKey).(string), 10, 64)
175				}
176				balances.Set(balKey, strconv.FormatUint(existing+amt, 10))
177			}
178		}
179
180		pendingMints.Remove(pendingKey)
181	}
182}
183
184// ── Casino ───────────────────────────────────────────────────────────────────
185
186func CommitCasinoSeed(cur realm, roundId string, seedHash string) {
187	if !isAdmin(cur) {
188		panic("unauthorized")
189	}
190	casinoRounds.Set(roundId, `{"roundId":"`+roundId+`","seedHash":"`+seedHash+`","status":"committed","bets":[]}`)
191}
192
193func PlaceCasinoBet(cur realm, roundId string, game string, coinId string, betAmount string, choice string, clientSeed string) {
194	requireNotPaused()
195	caller := cur.Previous().Address().String()
196	amt, err := strconv.ParseUint(betAmount, 10, 64)
197	if err != nil || amt == 0 {
198		panic("invalid bet amount")
199	}
200
201	balKey := caller + ":" + coinId
202	existing := uint64(0)
203	if balances.Has(balKey) {
204		existing, _ = strconv.ParseUint(balances.Get(balKey).(string), 10, 64)
205	}
206	if existing < amt {
207		panic("insufficient balance")
208	}
209	balances.Set(balKey, strconv.FormatUint(existing-amt, 10))
210
211	if !casinoRounds.Has(roundId) {
212		panic("round not found")
213	}
214	bet := `{"addr":"` + caller + `","game":"` + game + `","coinId":"` + coinId + `","amount":` + betAmount + `,"choice":"` + choice + `","clientSeed":"` + clientSeed + `"}`
215	casinoRounds.Set(roundId+":bet:"+caller, bet)
216}
217
218func RevealCasinoSeed(cur realm, roundId string, serverSeed string) {
219	if !isAdmin(cur) {
220		panic("unauthorized")
221	}
222	casinoRounds.Set(roundId+":serverSeed", serverSeed)
223	casinoRounds.Set(roundId+":status", "revealed")
224}
225
226func RefundCasinoBet(cur realm, roundId string) {
227	requireNotPaused()
228	caller := cur.Previous().Address().String()
229	betKey := roundId + ":bet:" + caller
230	if !casinoRounds.Has(betKey) {
231		panic("no bet found for this round")
232	}
233	casinoRounds.Remove(betKey)
234}
235
236// ── PvP Lobbies ──────────────────────────────────────────────────────────────
237
238func CreatePvpLobby(cur realm, game string, coinId string, betAmount string) string {
239	requireNotPaused()
240	caller := cur.Previous().Address().String()
241	prefix := caller
242	if len(prefix) > 8 {
243		prefix = caller[:8]
244	}
245	lobbyId := ufmt.Sprintf("pvp-%s-%d", prefix, time.Now().Unix())
246	lobby := `{"lobbyId":"` + lobbyId + `","creator":"` + caller + `","game":"` + game + `","coinId":"` + coinId + `","betAmount":` + betAmount + `,"status":"open","round":1,"createdAt":` + strconv.FormatInt(time.Now().Unix(), 10) + `}`
247	pvpLobbies.Set(lobbyId, lobby)
248	return lobbyId
249}
250
251func JoinPvpLobby(cur realm, lobbyId string) {
252	requireNotPaused()
253	caller := cur.Previous().Address().String()
254	if !pvpLobbies.Has(lobbyId) {
255		panic("lobby not found")
256	}
257	pvpLobbies.Set(lobbyId+":challenger", caller)
258	pvpLobbies.Set(lobbyId+":status", "joined")
259}
260
261func CommitPvpMove(cur realm, lobbyId string, moveHash string) {
262	requireNotPaused()
263	caller := cur.Previous().Address().String()
264	pvpLobbies.Set(lobbyId+":commit:"+caller, moveHash)
265}
266
267// RevealPvpMove requires both players to have committed before either can reveal.
268func RevealPvpMove(cur realm, lobbyId string, choice string, salt string) {
269	requireNotPaused()
270	caller := cur.Previous().Address().String()
271
272	// Verify lobby exists and both players have committed
273	if !pvpLobbies.Has(lobbyId) {
274		panic("lobby not found")
275	}
276	lobbyJson := pvpLobbies.Get(lobbyId).(string)
277	// Extract creator address from JSON
278	creatorStart := strings.Index(lobbyJson, `"creator":"`) + len(`"creator":"`)
279	creatorEnd := strings.Index(lobbyJson[creatorStart:], `"`) + creatorStart
280	creator := lobbyJson[creatorStart:creatorEnd]
281
282	// Require challenger has joined
283	if !pvpLobbies.Has(lobbyId + ":challenger") {
284		panic("no opponent has joined yet")
285	}
286	challenger := pvpLobbies.Get(lobbyId + ":challenger").(string)
287
288	// Require both commits are present
289	if !pvpLobbies.Has(lobbyId+":commit:"+creator) || !pvpLobbies.Has(lobbyId+":commit:"+challenger) {
290		panic("both players must commit moves before revealing")
291	}
292
293	pvpLobbies.Set(lobbyId+":reveal:"+caller, choice+":"+salt)
294	pvpLobbies.Set(lobbyId+":status", "revealed")
295}
296
297// ClaimPvpTimeout allows a lobby participant to claim a timeout win.
298// Only the creator or challenger may call this.
299func ClaimPvpTimeout(cur realm, lobbyId string) {
300	requireNotPaused()
301	caller := cur.Previous().Address().String()
302
303	if !pvpLobbies.Has(lobbyId) {
304		panic("lobby not found")
305	}
306	lobbyJson := pvpLobbies.Get(lobbyId).(string)
307
308	// Extract creator address
309	creatorStart := strings.Index(lobbyJson, `"creator":"`) + len(`"creator":"`)
310	creatorEnd := strings.Index(lobbyJson[creatorStart:], `"`) + creatorStart
311	creator := lobbyJson[creatorStart:creatorEnd]
312
313	// Get challenger (may not exist yet)
314	challenger := ""
315	if pvpLobbies.Has(lobbyId + ":challenger") {
316		challenger = pvpLobbies.Get(lobbyId + ":challenger").(string)
317	}
318
319	// Verify caller is a participant
320	if caller != creator && caller != challenger {
321		panic("not a lobby participant")
322	}
323
324	// Require minimum 10 minutes since lobby creation
325	createdAtStart := strings.Index(lobbyJson, `"createdAt":`) + len(`"createdAt":`)
326	createdAtEnd := strings.IndexAny(lobbyJson[createdAtStart:], `,}`) + createdAtStart
327	createdAt, _ := strconv.ParseInt(lobbyJson[createdAtStart:createdAtEnd], 10, 64)
328	if time.Now().Unix()-createdAt < 600 {
329		panic("timeout not yet claimable — minimum 10 minutes must elapse")
330	}
331
332	pvpLobbies.Set(lobbyId+":winner", caller)
333	pvpLobbies.Set(lobbyId+":status", "finished")
334}
335
336func ExpireLobbies(cur realm) {
337	if !isAdmin(cur) {
338		panic("unauthorized")
339	}
340}
341
342// ── Tools ────────────────────────────────────────────────────────────────────
343
344func BuyTool(cur realm, toolId string) {
345	requireNotPaused()
346	caller := cur.Previous().Address().String()
347	existing := ""
348	if ownedTools.Has(caller) {
349		existing = ownedTools.Get(caller).(string)
350	}
351	if existing == "" {
352		ownedTools.Set(caller, toolId)
353	} else {
354		ownedTools.Set(caller, existing+","+toolId)
355	}
356}
357
358func RepairTool(cur realm, toolId string) {
359	requireNotPaused()
360	caller := cur.Previous().Address().String()
361	toolLife.Set(caller+":"+toolId, "10000")
362}
363
364// ── Progression ──────────────────────────────────────────────────────────────
365
366func TrainSkill(cur realm, skillId string) {
367	requireNotPaused()
368	caller := cur.Previous().Address().String()
369	key := caller + ":" + skillId
370	rank := uint64(0)
371	if skillRanks.Has(key) {
372		rank, _ = strconv.ParseUint(skillRanks.Get(key).(string), 10, 64)
373	}
374	skillRanks.Set(key, strconv.FormatUint(rank+1, 10))
375}
376
377func ClaimAchievement(cur realm, achievementId string) {
378	requireNotPaused()
379	caller := cur.Previous().Address().String()
380	key := caller + ":" + achievementId
381	if achievements.Has(key) {
382		panic("already claimed")
383	}
384	achievements.Set(key, "1")
385}
386
387func DailyCheckin(cur realm) {
388	requireNotPaused()
389	caller := cur.Previous().Address().String()
390	now := time.Now().Unix()
391	if dailyCheckins.Has(caller) {
392		last, _ := strconv.ParseInt(dailyCheckins.Get(caller).(string), 10, 64)
393		if now-last < 72000 {
394			panic("too soon for daily check-in")
395		}
396	}
397	dailyCheckins.Set(caller, strconv.FormatInt(now, 10))
398}
399
400// CreateTask registers a new task on-chain.
401func CreateTask(cur realm, taskId string, desc string) {
402	requireNotPaused()
403	caller := cur.Previous().Address().String()
404	// Store task metadata keyed by taskId; only the creator can create it
405	achievements.Set("task:"+taskId+":creator", caller)
406	achievements.Set("task:"+taskId+":desc", desc)
407}
408
409func CompleteTask(cur realm, taskId string) {
410	requireNotPaused()
411	caller := cur.Previous().Address().String()
412	achievements.Set(caller+":"+taskId, "task:1")
413}
414
415func RegisterVein(cur realm, coinId string, maxSupply string) {
416	if !isAdmin(cur) {
417		panic("unauthorized")
418	}
419	veinSupply.Set(coinId, maxSupply+":"+maxSupply)
420}
421
422// ── Queries (pure reads — no cur realm needed) ────────────────────────────────
423
424func PendingMints(playerAddr string) string {
425	var parts []string
426	pendingMints.Iterate(playerAddr+":", playerAddr+":\xFF", func(key string, val any) bool {
427		coinId := strings.TrimPrefix(key, playerAddr+":")
428		parts = append(parts, `["`+coinId+`","`+val.(string)+`"]`)
429		return false
430	})
431	return ufmt.Sprintf(`{"pending":[%s]}`, strings.Join(parts, ","))
432}
433
434func AllBalances(playerAddr string) string {
435	var parts []string
436	balances.Iterate(playerAddr+":", playerAddr+":\xFF", func(key string, val any) bool {
437		coinId := strings.TrimPrefix(key, playerAddr+":")
438		parts = append(parts, `"`+coinId+`":`+val.(string))
439		return false
440	})
441	return "{" + strings.Join(parts, ",") + "}"
442}
443
444func VeinSupply(coinId string) string {
445	if !veinSupply.Has(coinId) {
446		return `{"remaining":"0","max":"0"}`
447	}
448	p := strings.SplitN(veinSupply.Get(coinId).(string), ":", 2)
449	if len(p) != 2 {
450		return `{"remaining":"0","max":"0"}`
451	}
452	return `{"remaining":"` + p[0] + `","max":"` + p[1] + `"}`
453}
454
455func CasinoRound(roundId string) string {
456	if !casinoRounds.Has(roundId) {
457		return "null"
458	}
459	return casinoRounds.Get(roundId).(string)
460}
461
462func CasinoHistory(_ string) string { return "[]" }
463
464func PvpLobby(lobbyId string) string {
465	if !pvpLobbies.Has(lobbyId) {
466		return "null"
467	}
468	return pvpLobbies.Get(lobbyId).(string)
469}
470
471func OpenLobbies() string {
472	var parts []string
473	pvpLobbies.Iterate("pvp-", "pvp-\xFF", func(key string, val any) bool {
474		if !strings.Contains(key, ":") {
475			parts = append(parts, val.(string))
476		}
477		return false
478	})
479	return "[" + strings.Join(parts, ",") + "]"
480}
481
482func JackpotPool(coinId string) string {
483	return `{"pool":"0","coinId":"` + coinId + `"}`
484}
485
486func PlayerProfile(playerAddr string) string {
487	return `{"address":"` + playerAddr + `"}`
488}
489
490func OwnedTools(playerAddr string) string {
491	if !ownedTools.Has(playerAddr) {
492		return `["hands"]`
493	}
494	ids := strings.Split(ownedTools.Get(playerAddr).(string), ",")
495	var quoted []string
496	for _, id := range ids {
497		quoted = append(quoted, `"`+id+`"`)
498	}
499	return "[" + strings.Join(quoted, ",") + "]"
500}
501
502func LifetimeMined(playerAddr string) string {
503	var parts []string
504	lifetimeMined.Iterate(playerAddr+":", playerAddr+":\xFF", func(key string, val any) bool {
505		coinId := strings.TrimPrefix(key, playerAddr+":")
506		parts = append(parts, `"`+coinId+`":`+val.(string))
507		return false
508	})
509	return "{" + strings.Join(parts, ",") + "}"
510}
511
512func AllMiners(limit int, offset int) string {
513	var parts []string
514	count, skipped := 0, 0
515	miners.Iterate("", "\xFF", func(key string, val any) bool {
516		if skipped < offset {
517			skipped++
518			return false
519		}
520		if count >= limit {
521			return true
522		}
523		display := key
524		if len(display) > 12 {
525			display = display[:8] + "..."
526		}
527		parts = append(parts, `{"address":"`+key+`","display":"`+display+`","total":`+val.(string)+`}`)
528		count++
529		return false
530	})
531	return "[" + strings.Join(parts, ",") + "]"
532}
533
534func GetMedals(playerAddr string) string {
535	var claimed []string
536	achievements.Iterate(playerAddr+":", playerAddr+":\xFF", func(key string, val any) bool {
537		achId := strings.TrimPrefix(key, playerAddr+":")
538		claimed = append(claimed, `"`+achId+`"`)
539		return false
540	})
541	return ufmt.Sprintf(`{"medals":[%s]}`, strings.Join(claimed, ","))
542}
543
544func DailyCheckinStatus(playerAddr string) string {
545	now := time.Now().Unix()
546	last := int64(0)
547	if dailyCheckins.Has(playerAddr) {
548		last, _ = strconv.ParseInt(dailyCheckins.Get(playerAddr).(string), 10, 64)
549	}
550	done := now-last < 72000
551	return ufmt.Sprintf(`{"ok":true,"done":%t,"streak":1,"next":%d}`, done, last+86400)
552}
553
554func ListTasks(_ string) string { return `{"ok":true,"tasks":[]}` }
555
556// ── Render ───────────────────────────────────────────────────────────────────
557
558func Render(_ string) string {
559	return ufmt.Sprintf("# GnoMoji Game Realm\n\nAdmin: `%s`\n\nPath: `gno.land/r/g138gdmwj6heduu3st796lw6qqdwylgaxf7ty5gz/game3`\n\nMine emoji coins on Gno.land!\n", admin.String())
560}
561
562// ── Helpers ──────────────────────────────────────────────────────────────────
563
564func splitJsonArray(s string) []string {
565	var result []string
566	depth, start := 0, 0
567	for i := 0; i < len(s); i++ {
568		switch s[i] {
569		case '[', '{':
570			depth++
571		case ']', '}':
572			depth--
573		case ',':
574			if depth == 0 {
575				if e := strings.TrimSpace(s[start:i]); e != "" {
576					result = append(result, e)
577				}
578				start = i + 1
579			}
580		}
581	}
582	if e := strings.TrimSpace(s[start:]); e != "" {
583		result = append(result, e)
584	}
585	return result
586}