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

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