game.gno
15.05 Kb · 493 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 game
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 paused bool
38)
39
40func init(cur realm) {
41 admin = cur.Address()
42}
43
44// ── Admin ────────────────────────────────────────────────────────────────────
45
46func isAdmin(cur realm) bool {
47 return cur.Previous().Address() == admin
48}
49
50func UpdateConfig(cur realm, newAdmin string) {
51 if !isAdmin(cur) {
52 panic("unauthorized")
53 }
54 admin = address(newAdmin)
55}
56
57func SetPaused(cur realm, p bool) {
58 if !isAdmin(cur) {
59 panic("unauthorized")
60 }
61 paused = p
62}
63
64func requireNotPaused() {
65 if paused {
66 panic("game is paused")
67 }
68}
69
70// ── Mining ───────────────────────────────────────────────────────────────────
71
72// ClaimMine records mined coins into pending mints for the caller.
73// coinsJson format: [["coinId","amount"],...]
74func ClaimMine(cur realm, coinsJson string) {
75 requireNotPaused()
76 caller := cur.Previous().Address().String()
77
78 coinsJson = strings.TrimSpace(coinsJson)
79 coinsJson = strings.TrimPrefix(coinsJson, "[")
80 coinsJson = strings.TrimSuffix(coinsJson, "]")
81
82 for _, entry := range splitJsonArray(coinsJson) {
83 entry = strings.TrimSpace(entry)
84 entry = strings.TrimPrefix(entry, "[")
85 entry = strings.TrimSuffix(entry, "]")
86 parts := strings.SplitN(entry, ",", 2)
87 if len(parts) != 2 {
88 continue
89 }
90 coinId := strings.Trim(strings.TrimSpace(parts[0]), `"`)
91 amtStr := strings.Trim(strings.TrimSpace(parts[1]), `"`)
92 amt, err := strconv.ParseUint(amtStr, 10, 64)
93 if err != nil || amt == 0 {
94 continue
95 }
96
97 // Cap at vein supply if registered
98 if veinSupply.Has(coinId) {
99 vsStr := veinSupply.Get(coinId).(string)
100 p2 := strings.SplitN(vsStr, ":", 2)
101 if len(p2) == 2 {
102 rem, _ := strconv.ParseUint(p2[0], 10, 64)
103 if rem == 0 {
104 continue
105 }
106 if amt > rem {
107 amt = rem
108 }
109 maxV, _ := strconv.ParseUint(p2[1], 10, 64)
110 veinSupply.Set(coinId, ufmt.Sprintf("%d:%d", rem-amt, maxV))
111 }
112 }
113
114 key := caller + ":" + coinId
115
116 // Accumulate pending mints
117 ex := uint64(0)
118 if pendingMints.Has(key) {
119 ex, _ = strconv.ParseUint(pendingMints.Get(key).(string), 10, 64)
120 }
121 pendingMints.Set(key, strconv.FormatUint(ex+amt, 10))
122
123 // Track lifetime mined per coin
124 lifEx := uint64(0)
125 if lifetimeMined.Has(key) {
126 lifEx, _ = strconv.ParseUint(lifetimeMined.Get(key).(string), 10, 64)
127 }
128 lifetimeMined.Set(key, strconv.FormatUint(lifEx+amt, 10))
129
130 // Update leaderboard total (all coins summed)
131 mt := uint64(0)
132 if miners.Has(caller) {
133 mt, _ = strconv.ParseUint(miners.Get(caller).(string), 10, 64)
134 }
135 miners.Set(caller, strconv.FormatUint(mt+amt, 10))
136 }
137}
138
139// ClearPendingMints is called by the minter realm after successful GRC20 minting.
140func ClearPendingMints(cur realm, playerAddr string, coinsJson string) {
141 if !isAdmin(cur) {
142 panic("unauthorized: only admin/minter can clear pending mints")
143 }
144 coinsJson = strings.TrimSpace(coinsJson)
145 coinsJson = strings.TrimPrefix(coinsJson, "[")
146 coinsJson = strings.TrimSuffix(coinsJson, "]")
147 for _, entry := range splitJsonArray(coinsJson) {
148 coinId := strings.Trim(strings.TrimSpace(entry), `"`)
149 if coinId != "" {
150 pendingMints.Remove(playerAddr + ":" + coinId)
151 }
152 }
153}
154
155// ── Casino ───────────────────────────────────────────────────────────────────
156
157func CommitCasinoSeed(cur realm, roundId string, seedHash string) {
158 if !isAdmin(cur) {
159 panic("unauthorized")
160 }
161 casinoRounds.Set(roundId, `{"roundId":"`+roundId+`","seedHash":"`+seedHash+`","status":"committed","bets":[]}`)
162}
163
164func PlaceCasinoBet(cur realm, roundId string, game string, coinId string, betAmount string, choice string, clientSeed string) {
165 requireNotPaused()
166 caller := cur.Previous().Address().String()
167 amt, err := strconv.ParseUint(betAmount, 10, 64)
168 if err != nil || amt == 0 {
169 panic("invalid bet amount")
170 }
171
172 balKey := caller + ":" + coinId
173 existing := uint64(0)
174 if balances.Has(balKey) {
175 existing, _ = strconv.ParseUint(balances.Get(balKey).(string), 10, 64)
176 }
177 if existing < amt {
178 panic("insufficient balance")
179 }
180 balances.Set(balKey, strconv.FormatUint(existing-amt, 10))
181
182 if !casinoRounds.Has(roundId) {
183 panic("round not found")
184 }
185 bet := `{"addr":"` + caller + `","game":"` + game + `","coinId":"` + coinId + `","amount":` + betAmount + `,"choice":"` + choice + `","clientSeed":"` + clientSeed + `"}`
186 casinoRounds.Set(roundId+":bet:"+caller, bet)
187}
188
189func RevealCasinoSeed(cur realm, roundId string, serverSeed string) {
190 if !isAdmin(cur) {
191 panic("unauthorized")
192 }
193 casinoRounds.Set(roundId+":serverSeed", serverSeed)
194 casinoRounds.Set(roundId+":status", "revealed")
195}
196
197func RefundCasinoBet(cur realm, roundId string) {
198 requireNotPaused()
199 caller := cur.Previous().Address().String()
200 betKey := roundId + ":bet:" + caller
201 if !casinoRounds.Has(betKey) {
202 panic("no bet found for this round")
203 }
204 casinoRounds.Remove(betKey)
205}
206
207// ── PvP Lobbies ──────────────────────────────────────────────────────────────
208
209func CreatePvpLobby(cur realm, game string, coinId string, betAmount string) string {
210 requireNotPaused()
211 caller := cur.Previous().Address().String()
212 prefix := caller
213 if len(prefix) > 8 {
214 prefix = caller[:8]
215 }
216 lobbyId := ufmt.Sprintf("pvp-%s-%d", prefix, time.Now().Unix())
217 lobby := `{"lobbyId":"` + lobbyId + `","creator":"` + caller + `","game":"` + game + `","coinId":"` + coinId + `","betAmount":` + betAmount + `,"status":"open","round":1,"createdAt":` + strconv.FormatInt(time.Now().Unix(), 10) + `}`
218 pvpLobbies.Set(lobbyId, lobby)
219 return lobbyId
220}
221
222func JoinPvpLobby(cur realm, lobbyId string) {
223 requireNotPaused()
224 caller := cur.Previous().Address().String()
225 if !pvpLobbies.Has(lobbyId) {
226 panic("lobby not found")
227 }
228 pvpLobbies.Set(lobbyId+":challenger", caller)
229 pvpLobbies.Set(lobbyId+":status", "joined")
230}
231
232func CommitPvpMove(cur realm, lobbyId string, moveHash string) {
233 requireNotPaused()
234 caller := cur.Previous().Address().String()
235 pvpLobbies.Set(lobbyId+":commit:"+caller, moveHash)
236}
237
238func RevealPvpMove(cur realm, lobbyId string, choice string, salt string) {
239 requireNotPaused()
240 caller := cur.Previous().Address().String()
241 pvpLobbies.Set(lobbyId+":reveal:"+caller, choice+":"+salt)
242 pvpLobbies.Set(lobbyId+":status", "revealed")
243}
244
245func ClaimPvpTimeout(cur realm, lobbyId string) {
246 requireNotPaused()
247 caller := cur.Previous().Address().String()
248 pvpLobbies.Set(lobbyId+":winner", caller)
249 pvpLobbies.Set(lobbyId+":status", "finished")
250}
251
252func ExpireLobbies(cur realm) {
253 if !isAdmin(cur) {
254 panic("unauthorized")
255 }
256}
257
258// ── Tools ────────────────────────────────────────────────────────────────────
259
260func BuyTool(cur realm, toolId string) {
261 requireNotPaused()
262 caller := cur.Previous().Address().String()
263 existing := ""
264 if ownedTools.Has(caller) {
265 existing = ownedTools.Get(caller).(string)
266 }
267 if existing == "" {
268 ownedTools.Set(caller, toolId)
269 } else {
270 ownedTools.Set(caller, existing+","+toolId)
271 }
272}
273
274func RepairTool(cur realm, toolId string) {
275 requireNotPaused()
276 caller := cur.Previous().Address().String()
277 toolLife.Set(caller+":"+toolId, "10000")
278}
279
280// ── Progression ──────────────────────────────────────────────────────────────
281
282func TrainSkill(cur realm, skillId string) {
283 requireNotPaused()
284 caller := cur.Previous().Address().String()
285 key := caller + ":" + skillId
286 rank := uint64(0)
287 if skillRanks.Has(key) {
288 rank, _ = strconv.ParseUint(skillRanks.Get(key).(string), 10, 64)
289 }
290 skillRanks.Set(key, strconv.FormatUint(rank+1, 10))
291}
292
293func ClaimAchievement(cur realm, achievementId string) {
294 requireNotPaused()
295 caller := cur.Previous().Address().String()
296 key := caller + ":" + achievementId
297 if achievements.Has(key) {
298 panic("already claimed")
299 }
300 achievements.Set(key, "1")
301}
302
303func DailyCheckin(cur realm) {
304 requireNotPaused()
305 caller := cur.Previous().Address().String()
306 now := time.Now().Unix()
307 if dailyCheckins.Has(caller) {
308 last, _ := strconv.ParseInt(dailyCheckins.Get(caller).(string), 10, 64)
309 if now-last < 72000 {
310 panic("too soon for daily check-in")
311 }
312 }
313 dailyCheckins.Set(caller, strconv.FormatInt(now, 10))
314}
315
316func CompleteTask(cur realm, taskId string) {
317 requireNotPaused()
318 caller := cur.Previous().Address().String()
319 achievements.Set(caller+":"+taskId, "task:1")
320}
321
322func RegisterVein(cur realm, coinId string, maxSupply string) {
323 if !isAdmin(cur) {
324 panic("unauthorized")
325 }
326 veinSupply.Set(coinId, maxSupply+":"+maxSupply)
327}
328
329// ── Queries (pure reads — no cur realm needed) ────────────────────────────────
330
331func PendingMints(playerAddr string) string {
332 var parts []string
333 pendingMints.Iterate(playerAddr+":", playerAddr+":\xFF", func(key string, val any) bool {
334 coinId := strings.TrimPrefix(key, playerAddr+":")
335 parts = append(parts, `["`+coinId+`","`+val.(string)+`"]`)
336 return false
337 })
338 return ufmt.Sprintf(`{"pending":[%s]}`, strings.Join(parts, ","))
339}
340
341func AllBalances(playerAddr string) string {
342 var parts []string
343 balances.Iterate(playerAddr+":", playerAddr+":\xFF", func(key string, val any) bool {
344 coinId := strings.TrimPrefix(key, playerAddr+":")
345 parts = append(parts, `"`+coinId+`":`+val.(string))
346 return false
347 })
348 return "{" + strings.Join(parts, ",") + "}"
349}
350
351func VeinSupply(coinId string) string {
352 if !veinSupply.Has(coinId) {
353 return `{"remaining":"0","max":"0"}`
354 }
355 p := strings.SplitN(veinSupply.Get(coinId).(string), ":", 2)
356 if len(p) != 2 {
357 return `{"remaining":"0","max":"0"}`
358 }
359 return `{"remaining":"` + p[0] + `","max":"` + p[1] + `"}`
360}
361
362func CasinoRound(roundId string) string {
363 if !casinoRounds.Has(roundId) {
364 return "null"
365 }
366 return casinoRounds.Get(roundId).(string)
367}
368
369func CasinoHistory(_ string) string { return "[]" }
370
371func PvpLobby(lobbyId string) string {
372 if !pvpLobbies.Has(lobbyId) {
373 return "null"
374 }
375 return pvpLobbies.Get(lobbyId).(string)
376}
377
378func OpenLobbies() string {
379 var parts []string
380 pvpLobbies.Iterate("pvp-", "pvp-\xFF", func(key string, val any) bool {
381 if !strings.Contains(key, ":") {
382 parts = append(parts, val.(string))
383 }
384 return false
385 })
386 return "[" + strings.Join(parts, ",") + "]"
387}
388
389func JackpotPool(coinId string) string {
390 return `{"pool":"0","coinId":"` + coinId + `"}`
391}
392
393func PlayerProfile(playerAddr string) string {
394 return `{"address":"` + playerAddr + `"}`
395}
396
397func OwnedTools(playerAddr string) string {
398 if !ownedTools.Has(playerAddr) {
399 return `["hands"]`
400 }
401 ids := strings.Split(ownedTools.Get(playerAddr).(string), ",")
402 var quoted []string
403 for _, id := range ids {
404 quoted = append(quoted, `"`+id+`"`)
405 }
406 return "[" + strings.Join(quoted, ",") + "]"
407}
408
409func LifetimeMined(playerAddr string) string {
410 var parts []string
411 lifetimeMined.Iterate(playerAddr+":", playerAddr+":\xFF", func(key string, val any) bool {
412 coinId := strings.TrimPrefix(key, playerAddr+":")
413 parts = append(parts, `"`+coinId+`":`+val.(string))
414 return false
415 })
416 return "{" + strings.Join(parts, ",") + "}"
417}
418
419func AllMiners(limit int, offset int) string {
420 var parts []string
421 count, skipped := 0, 0
422 miners.Iterate("", "\xFF", func(key string, val any) bool {
423 if skipped < offset {
424 skipped++
425 return false
426 }
427 if count >= limit {
428 return true
429 }
430 display := key
431 if len(display) > 12 {
432 display = display[:8] + "..."
433 }
434 parts = append(parts, `{"address":"`+key+`","display":"`+display+`","total":`+val.(string)+`}`)
435 count++
436 return false
437 })
438 return "[" + strings.Join(parts, ",") + "]"
439}
440
441func GetMedals(playerAddr string) string {
442 var claimed []string
443 achievements.Iterate(playerAddr+":", playerAddr+":\xFF", func(key string, val any) bool {
444 achId := strings.TrimPrefix(key, playerAddr+":")
445 claimed = append(claimed, `"`+achId+`"`)
446 return false
447 })
448 return ufmt.Sprintf(`{"medals":[%s]}`, strings.Join(claimed, ","))
449}
450
451func DailyCheckinStatus(playerAddr string) string {
452 now := time.Now().Unix()
453 last := int64(0)
454 if dailyCheckins.Has(playerAddr) {
455 last, _ = strconv.ParseInt(dailyCheckins.Get(playerAddr).(string), 10, 64)
456 }
457 done := now-last < 72000
458 return ufmt.Sprintf(`{"ok":true,"done":%t,"streak":1,"next":%d}`, done, last+86400)
459}
460
461func ListTasks(_ string) string { return `{"ok":true,"tasks":[]}` }
462
463// ── Render ───────────────────────────────────────────────────────────────────
464
465func Render(_ string) string {
466 return ufmt.Sprintf("# GnoMoji Game Realm\n\nAdmin: `%s`\n\nPath: `gno.land/r/g138gdmwj6heduu3st796lw6qqdwylgaxf7ty5gz/game`\n\nMine emoji coins on Gno.land!\n", admin.String())
467}
468
469// ── Helpers ──────────────────────────────────────────────────────────────────
470
471func splitJsonArray(s string) []string {
472 var result []string
473 depth, start := 0, 0
474 for i := 0; i < len(s); i++ {
475 switch s[i] {
476 case '[', '{':
477 depth++
478 case ']', '}':
479 depth--
480 case ',':
481 if depth == 0 {
482 if e := strings.TrimSpace(s[start:i]); e != "" {
483 result = append(result, e)
484 }
485 start = i + 1
486 }
487 }
488 }
489 if e := strings.TrimSpace(s[start:]); e != "" {
490 result = append(result, e)
491 }
492 return result
493}