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}