game.gno
35.91 Kb · 976 lines
1// GnoMoji — Main Game Realm
2// gno.land/r/g138gdmwj6heduu3st796lw6qqdwylgaxf7ty5gz/game6
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/minter5.
8// Unified coin balances live at gno.land/r/g138gdmwj6heduu3st796lw6qqdwylgaxf7ty5gz/coins.
9// Phase 2: On-chain cost enforcement for BuyTool, RepairTool, and TrainSkill.
10// Phase 3: Wallet blacklist — BanAddress, UnbanAddress, BlacklistStatus, isBlacklisted.
11
12package game6
13
14import (
15 "strconv"
16 "strings"
17 "time"
18
19 "gno.land/p/nt/avl/v0"
20 "gno.land/p/nt/ufmt/v0"
21)
22
23// ── State ────────────────────────────────────────────────────────────────────
24
25var (
26 admin address
27 minterAddr address // authorized minter realm (minter5) — set via SetMinter
28 pendingMints avl.Tree // addr+":"+coinId → amount string
29 balances avl.Tree // addr+":"+coinId → amount string
30 veinSupply avl.Tree // coinId → "remaining:max"
31 casinoRounds avl.Tree // roundId → JSON string
32 pvpLobbies avl.Tree // lobbyId → JSON string
33 ownedTools avl.Tree // addr → comma-separated tool IDs
34 toolLife avl.Tree // addr+":"+toolId → remaining life string
35 lifetimeMined avl.Tree // addr+":"+coinId → total mined string
36 achievements avl.Tree // addr+":"+achId → "1"
37 dailyCheckins avl.Tree // addr → unix seconds string
38 skillRanks avl.Tree // addr+":"+skillId → rank string
39 miners avl.Tree // addr → total mined all-time string
40 lastClaim avl.Tree // addr → unix timestamp of last ClaimMine (velocity limit)
41 blacklist avl.Tree // addr → "reason:timestamp"
42 banExpiries avl.Tree // addr → unix expiry (0 = permanent)
43 paused bool
44)
45
46// ── Cost Tables ───────────────────────────────────────────────────────────────
47
48// toolCosts maps toolId → slice of [coinId, amount] pairs.
49// Copied from catalog.ts TOOLS array. Amounts are for qty=1.
50var toolCosts = map[string][][2]string{
51 "twig": {{"grin", "120"}, {"joy", "75"}, {"cool", "60"}},
52 "wood": {{"seedling", "180"}, {"leaves", "150"}, {"fallen", "120"}, {"think", "105"}, {"sleep", "90"}, {"party", "75"}},
53 "stone": {{"rock", "600"}, {"log", "540"}, {"evergreen", "480"}, {"brick", "420"}, {"mushroom", "360"}, {"heart", "300"}},
54 "iron": {{"gear", "840"}, {"chains", "780"}, {"wrench", "720"}, {"hammer", "660"}, {"anchor", "630"}, {"pick", "570"}},
55 "gold": {{"gold-medal", "1140"}, {"crown", "1080"}, {"trophy", "1020"}, {"money-bag", "960"}, {"bolt", "930"}, {"sword", "870"}},
56 "diamond": {{"gem", "1560"}, {"coin", "1440"}, {"ring", "1380"}, {"disco", "1320"}, {"kite", "1260"}, {"dodo", "1140"}},
57 "cosmic": {{"galaxy", "1950"}, {"moai", "1860"}, {"hamsa", "1800"}, {"mirror", "1740"}, {"atom", "1650"}, {"ufo", "1500"}},
58 "plasma": {{"galaxy", "2250"}, {"atom", "2100"}, {"moai", "2100"}, {"comet", "1950"}, {"jelly", "1800"}, {"peacock", "1800"}},
59 "neutron": {{"jelly", "2850"}, {"peacock", "2700"}, {"unicorn", "2550"}, {"galaxy", "2400"}, {"hamsa", "2400"}, {"dna", "2100"}},
60 "quasar": {{"unicorn", "3450"}, {"peacock", "3300"}, {"jelly", "3300"}, {"lotus", "2550"}, {"galaxy", "2850"}, {"dragon", "2550"}},
61 "void": {{"lotus", "3600"}, {"unicorn", "4350"}, {"dragon", "4200"}, {"peacock", "4350"}, {"jelly", "4500"}, {"galaxy", "1500"}},
62 "genesis": {{"lotus", "5400"}, {"dragon", "5400"}, {"unicorn", "5250"}, {"peacock", "4950"}, {"jelly", "4800"}, {"infinity", "4200"}},
63 "omega": {{"lotus", "6900"}, {"dragon", "6600"}, {"unicorn", "6300"}, {"peacock", "6000"}, {"jelly", "6000"}, {"infinity", "5700"}},
64}
65
66// skillBaseCosts maps skillId → base cost at rank 0.
67// Actual cost at rank N = base × 2^N (doubles per rank).
68var skillBaseCosts = map[string][][2]string{
69 // STRIKE branch
70 "wrist": {{"grin", "80"}, {"joy", "20"}},
71 "palm_strike": {{"cool", "60"}, {"think", "40"}},
72 "follow": {{"fire", "40"}, {"up", "40"}},
73 "knuckle_wrap": {{"rock", "45"}, {"log", "40"}},
74 "heavy_swing": {{"brick", "35"}, {"evergreen", "30"}},
75 "stone_breaker": {{"rock", "70"}, {"hammer", "25"}, {"gear", "20"}},
76 "anvil_force": {{"chains", "35"}, {"wrench", "30"}, {"brick", "60"}},
77 "point_drive": {{"pick", "35"}, {"gear", "40"}, {"log", "90"}},
78 "kinetic_thrust": {{"rocket", "20"}, {"bolt", "12"}, {"fire", "70"}},
79 "crushing_blow": {{"crown", "10"}, {"trophy", "12"}, {"hammer", "45"}, {"bolt", "15"}},
80 "shatter_spike": {{"sword", "18"}, {"pick", "45"}, {"chains", "45"}, {"gold-medal", "12"}},
81 "ironclad_cleave": {{"anchor", "40"}, {"gear", "60"}, {"wrench", "50"}, {"money-bag", "15"}},
82 "thunder_jab": {{"bolt", "25"}, {"rocket", "30"}, {"fire", "120"}, {"wand", "15"}},
83 "titan_hammer": {{"gem", "14"}, {"coin", "18"}, {"crown", "22"}, {"trophy", "22"}, {"sword", "28"}},
84 "diamond_cleave": {{"gem", "20"}, {"ring", "12"}, {"disco", "15"}, {"pick", "70"}, {"gold-medal", "24"}},
85 "hyper_drive": {{"kite", "15"}, {"dodo", "12"}, {"bolt", "35"}, {"rocket", "45"}, {"gear", "70"}},
86 "quasar_impact": {{"galaxy", "18"}, {"moai", "18"}, {"atom", "20"}, {"ufo", "16"}, {"comet", "18"}},
87 "black_hole_crush": {{"galaxy", "30"}, {"atom", "28"}, {"moai", "24"}, {"gem", "35"}, {"ring", "28"}, {"comet", "22"}},
88 "mythic_annihilation": {{"dragon", "8"}, {"unicorn", "10"}, {"jelly", "12"}, {"peacock", "12"}, {"galaxy", "35"}, {"moai", "30"}},
89 "singularity_fist": {{"infinity", "6"}, {"lotus", "8"}, {"dragon", "10"}, {"unicorn", "12"}, {"jelly", "14"}, {"galaxy", "40"}},
90 // FORTUNE branch
91 "fortune": {{"clover", "16"}, {"heart", "40"}},
92 "four_leaf": {{"seedling", "60"}, {"leaves", "50"}},
93 "edge": {{"pizza", "20"}, {"spark", "30"}},
94 "glint": {{"spark", "50"}, {"star", "12"}},
95 "spark_chaser": {{"fire", "45"}, {"tada", "25"}},
96 "razor_focus": {{"coffee", "25"}, {"game", "25"}, {"hundred", "30"}},
97 "precision_cut": {{"eyes", "40"}, {"skull", "35"}, {"flutter", "18"}},
98 "aurora_gaze": {{"rainbow", "15"}, {"spark", "60"}, {"moon", "20"}},
99 "starlight_gleam": {{"star", "25"}, {"clover", "20"}, {"heart", "80"}},
100 "double_haul": {{"gear", "30"}, {"anchor", "20"}, {"up", "70"}},
101 "twin_strike": {{"chains", "25"}, {"wrench", "25"}, {"pick", "20"}},
102 "executioner": {{"skull", "60"}, {"sword", "18"}, {"crown", "10"}, {"orb", "8"}},
103 "celestial_sight": {{"planet", "10"}, {"moon", "30"}, {"star", "30"}, {"bead", "8"}},
104 "helix_harmony": {{"dna", "12"}, {"spark", "80"}, {"rainbow", "25"}, {"orb", "10"}},
105 "fortune_split": {{"money-bag", "15"}, {"gold-medal", "12"}, {"clover", "35"}, {"chains", "40"}},
106 "critical_surge": {{"gem", "15"}, {"disco", "14"}, {"ring", "12"}, {"orb", "20"}, {"planet", "18"}},
107 "astral_scintillation": {{"ring", "14"}, {"gem", "16"}, {"dna", "20"}, {"star", "60"}, {"moon", "50"}},
108 "plunder_frenzy": {{"coin", "25"}, {"money-bag", "30"}, {"gem", "12"}, {"crown", "25"}, {"trophy", "25"}},
109 "singularity_edge": {{"lotus", "6"}, {"dragon", "8"}, {"galaxy", "25"}, {"hamsa", "20"}, {"atom", "22"}, {"mirror", "20"}},
110 "cosmic_fortune": {{"peacock", "10"}, {"unicorn", "8"}, {"comet", "25"}, {"disco", "30"}, {"galaxy", "30"}, {"gem", "35"}},
111 // POCKET branch
112 "dustbag": {{"grin", "200"}, {"sleep", "40"}},
113 "rhythm": {{"music", "16"}, {"clap", "30"}},
114 "pocket_expansion": {{"fallen", "45"}, {"herb", "35"}},
115 "steady_tempo": {{"party", "40"}, {"wow", "30"}},
116 "cadence": {{"music", "30"}, {"coffee", "20"}, {"clap", "50"}},
117 "deep_pouch": {{"log", "70"}, {"rock", "60"}, {"mushroom", "35"}},
118 "vein_resonator": {{"flutter", "20"}, {"rainbow", "15"}, {"tada", "40"}},
119 "cart_bed": {{"log", "120"}, {"rock", "100"}, {"brick", "80"}, {"money-bag", "10"}},
120 "bounty_overflow": {{"trophy", "15"}, {"crown", "12"}, {"pick", "45"}, {"game", "35"}},
121 "fracture_pulse": {{"bolt", "25"}, {"wand", "15"}, {"hammer", "50"}, {"rocket", "25"}},
122 "metro_pulse": {{"music", "45"}, {"coffee", "35"}, {"raise", "30"}, {"bolt", "15"}},
123 "hoarders_tote": {{"coin", "22"}, {"gem", "10"}, {"money-bag", "35"}, {"rock", "150"}, {"log", "150"}},
124 "shatter_strike": {{"gem", "14"}, {"bolt", "35"}, {"wand", "25"}, {"sword", "30"}, {"hammer", "60"}},
125 "super_conductor": {{"coin", "20"}, {"ring", "12"}, {"music", "60"}, {"bolt", "30"}, {"coffee", "50"}},
126 "dodo_instinct": {{"dodo", "14"}, {"kite", "14"}, {"crown", "22"}, {"trophy", "22"}, {"eyes", "80"}},
127 "void_rend": {{"infinity", "6"}, {"jelly", "12"}, {"dragon", "8"}, {"atom", "30"}, {"comet", "25"}, {"bolt", "60"}},
128 "celestial_duplication": {{"infinity", "5"}, {"lotus", "6"}, {"dragon", "8"}, {"peacock", "10"}, {"coin", "40"}, {"gem", "35"}},
129 "eternity_flow": {{"infinity", "5"}, {"lotus", "6"}, {"peacock", "8"}, {"hamsa", "25"}, {"mirror", "25"}, {"music", "100"}},
130 "abyssal_reserve": {{"unicorn", "6"}, {"jelly", "10"}, {"moai", "25"}, {"ufo", "25"}, {"money-bag", "50"}, {"rock", "300"}},
131 "singularity_snap": {{"infinity", "8"}, {"lotus", "8"}, {"dragon", "10"}, {"galaxy", "40"}, {"moai", "30"}, {"bolt", "80"}},
132}
133
134// skillMaxRanks maps skillId → maximum rank allowed.
135// Most skills cap at 3 or 4 — see catalog.ts SKILLS[*].max.
136var skillMaxRanks = map[string]uint64{
137 // Strike branch (max varies: 3–5)
138 "wrist": 5, "palm_strike": 4, "follow": 3, "knuckle_wrap": 4, "heavy_swing": 4,
139 "stone_breaker": 4, "anvil_force": 4, "point_drive": 4, "kinetic_thrust": 4,
140 "crushing_blow": 4, "shatter_spike": 4, "ironclad_cleave": 3, "thunder_jab": 3,
141 "titan_hammer": 3, "diamond_cleave": 3, "hyper_drive": 3, "quasar_impact": 3,
142 "black_hole_crush": 3, "mythic_annihilation": 3, "singularity_fist": 3,
143 // Fortune branch (all max 3 or 4)
144 "fortune": 4, "four_leaf": 3, "edge": 3, "glint": 3, "spark_chaser": 3,
145 "razor_focus": 3, "precision_cut": 3, "aurora_gaze": 3, "starlight_gleam": 3,
146 "double_haul": 3, "twin_strike": 3, "executioner": 3, "celestial_sight": 3,
147 "helix_harmony": 3, "fortune_split": 3, "critical_surge": 3,
148 "astral_scintillation": 3, "plunder_frenzy": 3, "singularity_edge": 3, "cosmic_fortune": 3,
149 // Pocket branch (all max 3)
150 "dustbag": 3, "rhythm": 3, "pocket_expansion": 3, "steady_tempo": 3, "cadence": 3,
151 "deep_pouch": 3, "vein_resonator": 3, "cart_bed": 3, "bounty_overflow": 3,
152 "fracture_pulse": 3, "metro_pulse": 3, "hoarders_tote": 3, "shatter_strike": 3,
153 "super_conductor": 3, "dodo_instinct": 3, "void_rend": 3, "celestial_duplication": 3,
154 "eternity_flow": 3, "abyssal_reserve": 3, "singularity_snap": 3,
155}
156
157// toolMaxLife maps toolId → maximum durability (from catalog.ts tool.life).
158// Tools with life=0 are indestructible (hands, twig).
159var toolMaxLife = map[string]uint64{
160 "hands": 0, "twig": 0, "wood": 1200, "stone": 2500, "iron": 4000,
161 "gold": 6000, "diamond": 8000, "cosmic": 10000, "plasma": 10000,
162 "neutron": 10000, "quasar": 10000, "void": 12000, "genesis": 15000, "omega": 25000,
163}
164
165func init(cur realm) {
166 admin = cur.Previous().Address()
167}
168
169// ── Admin ────────────────────────────────────────────────────────────────────
170
171func isAdmin(cur realm) bool {
172 caller := cur.Previous().Address()
173 return caller == admin || (minterAddr != address("") && caller == minterAddr)
174}
175
176// SetMinter registers the authorized minter realm address so it can call
177// ClearPendingMints. Called once after minter5 is deployed.
178func SetMinter(cur realm, newMinter string) {
179 if cur.Previous().Address() != admin {
180 panic("unauthorized: only admin can set minter")
181 }
182 minterAddr = address(newMinter)
183}
184
185func UpdateConfig(cur realm, newAdmin string) {
186 if !isAdmin(cur) {
187 panic("unauthorized")
188 }
189 admin = address(newAdmin)
190}
191
192func SetPaused(cur realm, p bool) {
193 if !isAdmin(cur) {
194 panic("unauthorized")
195 }
196 paused = p
197}
198
199func requireNotPaused() {
200 if paused {
201 panic("game is paused")
202 }
203}
204
205// ── Blacklist ─────────────────────────────────────────────────────────────────
206
207// BanAddress bans a player address. durationSecs=0 means permanent.
208func BanAddress(cur realm, targetAddr string, reason string, durationSecs int64) {
209 if cur.Previous().Address() != admin {
210 panic("unauthorized: only admin can ban")
211 }
212 if !strings.HasPrefix(targetAddr, "g1") {
213 panic("invalid address")
214 }
215 expiry := int64(0) // 0 = permanent
216 if durationSecs > 0 {
217 expiry = time.Now().Unix() + durationSecs
218 }
219 timestamp := strconv.FormatInt(time.Now().Unix(), 10)
220 blacklist.Set(targetAddr, reason+":"+timestamp)
221 banExpiries.Set(targetAddr, strconv.FormatInt(expiry, 10))
222}
223
224// UnbanAddress removes a ban.
225func UnbanAddress(cur realm, targetAddr string) {
226 if cur.Previous().Address() != admin {
227 panic("unauthorized: only admin can unban")
228 }
229 blacklist.Remove(targetAddr)
230 banExpiries.Remove(targetAddr)
231}
232
233// BlacklistStatus returns the ban status of an address as a JSON string.
234func BlacklistStatus(addr string) string {
235 if !blacklist.Has(addr) {
236 return `{"banned":false}`
237 }
238 entry := blacklist.Get(addr).(string)
239 expiryStr := banExpiries.Get(addr).(string)
240 expiry, _ := strconv.ParseInt(expiryStr, 10, 64)
241 now := time.Now().Unix()
242 if expiry > 0 && now >= expiry {
243 // Expired ban — treat as clean
244 return `{"banned":false}`
245 }
246 parts := strings.SplitN(entry, ":", 2)
247 reason := parts[0]
248 permanent := expiry == 0
249 remainSecs := int64(0)
250 if !permanent && expiry > 0 {
251 remainSecs = expiry - now
252 }
253 if permanent {
254 return `{"banned":true,"reason":"` + reason + `","permanent":true}`
255 }
256 return `{"banned":true,"reason":"` + reason + `","permanent":false,"remainingSecs":` + strconv.FormatInt(remainSecs, 10) + `}`
257}
258
259// isBlacklisted checks if an address is currently banned (unexpired).
260func isBlacklisted(addr string) bool {
261 if !blacklist.Has(addr) {
262 return false
263 }
264 expiryStr := banExpiries.Get(addr).(string)
265 expiry, _ := strconv.ParseInt(expiryStr, 10, 64)
266 if expiry == 0 {
267 return true // permanent
268 }
269 return time.Now().Unix() < expiry
270}
271
272// ── Mining ───────────────────────────────────────────────────────────────────
273
274// ClaimMine records mined coins into pending mints for the caller.
275// coinsJson format: [["coinId","amount"],...]
276func ClaimMine(cur realm, coinsJson string) {
277 requireNotPaused()
278 caller := cur.Previous().Address().String()
279 if isBlacklisted(caller) {
280 panic("address banned")
281 }
282
283 // Velocity limit: minimum 5 seconds between ClaimMine calls per address
284 now := time.Now().Unix()
285 if lastClaim.Has(caller) {
286 last, _ := strconv.ParseInt(lastClaim.Get(caller).(string), 10, 64)
287 if now-last < 5 {
288 panic("claiming too fast — please wait 5 seconds between claims")
289 }
290 }
291 lastClaim.Set(caller, strconv.FormatInt(now, 10))
292
293 coinsJson = strings.TrimSpace(coinsJson)
294 coinsJson = strings.TrimPrefix(coinsJson, "[")
295 coinsJson = strings.TrimSuffix(coinsJson, "]")
296
297 for _, entry := range splitJsonArray(coinsJson) {
298 entry = strings.TrimSpace(entry)
299 entry = strings.TrimPrefix(entry, "[")
300 entry = strings.TrimSuffix(entry, "]")
301 parts := strings.SplitN(entry, ",", 2)
302 if len(parts) != 2 {
303 continue
304 }
305 coinId := strings.Trim(strings.TrimSpace(parts[0]), `"`)
306 amtStr := strings.Trim(strings.TrimSpace(parts[1]), `"`)
307 amt, err := strconv.ParseUint(amtStr, 10, 64)
308 if err != nil || amt == 0 {
309 continue
310 }
311
312 // Cap at vein supply if registered
313 if veinSupply.Has(coinId) {
314 vsStr := veinSupply.Get(coinId).(string)
315 p2 := strings.SplitN(vsStr, ":", 2)
316 if len(p2) == 2 {
317 rem, _ := strconv.ParseUint(p2[0], 10, 64)
318 if rem == 0 {
319 continue
320 }
321 if amt > rem {
322 amt = rem
323 }
324 maxV, _ := strconv.ParseUint(p2[1], 10, 64)
325 veinSupply.Set(coinId, ufmt.Sprintf("%d:%d", rem-amt, maxV))
326 }
327 }
328
329 key := caller + ":" + coinId
330
331 // Accumulate pending mints
332 ex := uint64(0)
333 if pendingMints.Has(key) {
334 ex, _ = strconv.ParseUint(pendingMints.Get(key).(string), 10, 64)
335 }
336 pendingMints.Set(key, strconv.FormatUint(ex+amt, 10))
337
338 // Track lifetime mined per coin
339 lifEx := uint64(0)
340 if lifetimeMined.Has(key) {
341 lifEx, _ = strconv.ParseUint(lifetimeMined.Get(key).(string), 10, 64)
342 }
343 lifetimeMined.Set(key, strconv.FormatUint(lifEx+amt, 10))
344
345 // Update leaderboard total (all coins summed)
346 mt := uint64(0)
347 if miners.Has(caller) {
348 mt, _ = strconv.ParseUint(miners.Get(caller).(string), 10, 64)
349 }
350 miners.Set(caller, strconv.FormatUint(mt+amt, 10))
351 }
352}
353
354// ClearPendingMints is called by the treasury server after successful GRC20 minting.
355// Removes pending balance and credits the player's spendable balances tree.
356func ClearPendingMints(cur realm, playerAddr string, coinsJson string) {
357 if !isAdmin(cur) {
358 panic("unauthorized: only admin/minter can clear pending mints")
359 }
360 coinsJson = strings.TrimSpace(coinsJson)
361 coinsJson = strings.TrimPrefix(coinsJson, "[")
362 coinsJson = strings.TrimSuffix(coinsJson, "]")
363 for _, entry := range splitJsonArray(coinsJson) {
364 coinId := strings.Trim(strings.TrimSpace(entry), `"`)
365 if coinId == "" {
366 continue
367 }
368 pendingKey := playerAddr + ":" + coinId
369
370 // Read minted amount before clearing, then credit to balances
371 if pendingMints.Has(pendingKey) {
372 amtStr := pendingMints.Get(pendingKey).(string)
373 amt, err := strconv.ParseUint(amtStr, 10, 64)
374 if err == nil && amt > 0 {
375 balKey := playerAddr + ":" + coinId
376 existing := uint64(0)
377 if balances.Has(balKey) {
378 existing, _ = strconv.ParseUint(balances.Get(balKey).(string), 10, 64)
379 }
380 balances.Set(balKey, strconv.FormatUint(existing+amt, 10))
381 }
382 }
383
384 pendingMints.Remove(pendingKey)
385 }
386}
387
388// ── Casino ───────────────────────────────────────────────────────────────────
389
390func CommitCasinoSeed(cur realm, roundId string, seedHash string) {
391 if !isAdmin(cur) {
392 panic("unauthorized")
393 }
394 casinoRounds.Set(roundId, `{"roundId":"`+roundId+`","seedHash":"`+seedHash+`","status":"committed","bets":[]}`)
395}
396
397func PlaceCasinoBet(cur realm, roundId string, game string, coinId string, betAmount string, choice string, clientSeed string) {
398 requireNotPaused()
399 caller := cur.Previous().Address().String()
400 if isBlacklisted(caller) {
401 panic("address banned")
402 }
403 amt, err := strconv.ParseUint(betAmount, 10, 64)
404 if err != nil || amt == 0 {
405 panic("invalid bet amount")
406 }
407
408 balKey := caller + ":" + coinId
409 existing := uint64(0)
410 if balances.Has(balKey) {
411 existing, _ = strconv.ParseUint(balances.Get(balKey).(string), 10, 64)
412 }
413 if existing < amt {
414 panic("insufficient balance")
415 }
416 balances.Set(balKey, strconv.FormatUint(existing-amt, 10))
417
418 if !casinoRounds.Has(roundId) {
419 panic("round not found")
420 }
421 bet := `{"addr":"` + caller + `","game":"` + game + `","coinId":"` + coinId + `","amount":` + betAmount + `,"choice":"` + choice + `","clientSeed":"` + clientSeed + `"}`
422 casinoRounds.Set(roundId+":bet:"+caller, bet)
423}
424
425func RevealCasinoSeed(cur realm, roundId string, serverSeed string) {
426 if !isAdmin(cur) {
427 panic("unauthorized")
428 }
429 casinoRounds.Set(roundId+":serverSeed", serverSeed)
430 casinoRounds.Set(roundId+":status", "revealed")
431}
432
433func RefundCasinoBet(cur realm, roundId string) {
434 requireNotPaused()
435 caller := cur.Previous().Address().String()
436 betKey := roundId + ":bet:" + caller
437 if !casinoRounds.Has(betKey) {
438 panic("no bet found for this round")
439 }
440
441 // Parse bet JSON to recover coinId and amount before removing the record.
442 // Bet JSON format: {"addr":"...","game":"...","coinId":"...","amount":N,...}
443 betJson := casinoRounds.Get(betKey).(string)
444
445 // Extract coinId (quoted string value)
446 coinId := ""
447 coinIdStart := strings.Index(betJson, `"coinId":"`) + len(`"coinId":"`)
448 if coinIdStart > len(`"coinId":"`) {
449 coinIdEnd := strings.Index(betJson[coinIdStart:], `"`) + coinIdStart
450 if coinIdEnd > coinIdStart {
451 coinId = betJson[coinIdStart:coinIdEnd]
452 }
453 }
454
455 // Extract amount (unquoted numeric value)
456 amountStr := ""
457 amountStart := strings.Index(betJson, `"amount":`) + len(`"amount":`)
458 if amountStart > len(`"amount":`) {
459 amountEnd := strings.IndexAny(betJson[amountStart:], `,}`) + amountStart
460 if amountEnd > amountStart {
461 amountStr = betJson[amountStart:amountEnd]
462 }
463 }
464
465 amount, _ := strconv.ParseUint(amountStr, 10, 64)
466 if amount > 0 && coinId != "" {
467 // Restore the deducted balance
468 balKey := caller + ":" + coinId
469 current := uint64(0)
470 if balances.Has(balKey) {
471 current, _ = strconv.ParseUint(balances.Get(balKey).(string), 10, 64)
472 }
473 balances.Set(balKey, strconv.FormatUint(current+amount, 10))
474 }
475
476 casinoRounds.Remove(betKey)
477}
478
479// ── PvP Lobbies ──────────────────────────────────────────────────────────────
480
481func CreatePvpLobby(cur realm, game string, coinId string, betAmount string) string {
482 requireNotPaused()
483 caller := cur.Previous().Address().String()
484 if isBlacklisted(caller) {
485 panic("address banned")
486 }
487 prefix := caller
488 if len(prefix) > 8 {
489 prefix = caller[:8]
490 }
491 lobbyId := ufmt.Sprintf("pvp-%s-%d", prefix, time.Now().Unix())
492 lobby := `{"lobbyId":"` + lobbyId + `","creator":"` + caller + `","game":"` + game + `","coinId":"` + coinId + `","betAmount":` + betAmount + `,"status":"open","round":1,"createdAt":` + strconv.FormatInt(time.Now().Unix(), 10) + `}`
493 pvpLobbies.Set(lobbyId, lobby)
494 return lobbyId
495}
496
497func JoinPvpLobby(cur realm, lobbyId string) {
498 requireNotPaused()
499 caller := cur.Previous().Address().String()
500 if !pvpLobbies.Has(lobbyId) {
501 panic("lobby not found")
502 }
503 pvpLobbies.Set(lobbyId+":challenger", caller)
504 pvpLobbies.Set(lobbyId+":status", "joined")
505}
506
507func CommitPvpMove(cur realm, lobbyId string, moveHash string) {
508 requireNotPaused()
509 caller := cur.Previous().Address().String()
510 pvpLobbies.Set(lobbyId+":commit:"+caller, moveHash)
511}
512
513// RevealPvpMove requires both players to have committed before either can reveal.
514func RevealPvpMove(cur realm, lobbyId string, choice string, salt string) {
515 requireNotPaused()
516 caller := cur.Previous().Address().String()
517
518 // Verify lobby exists and both players have committed
519 if !pvpLobbies.Has(lobbyId) {
520 panic("lobby not found")
521 }
522 lobbyJson := pvpLobbies.Get(lobbyId).(string)
523 // Extract creator address from JSON
524 creatorStart := strings.Index(lobbyJson, `"creator":"`) + len(`"creator":"`)
525 creatorEnd := strings.Index(lobbyJson[creatorStart:], `"`) + creatorStart
526 creator := lobbyJson[creatorStart:creatorEnd]
527
528 // Require challenger has joined
529 if !pvpLobbies.Has(lobbyId + ":challenger") {
530 panic("no opponent has joined yet")
531 }
532 challenger := pvpLobbies.Get(lobbyId + ":challenger").(string)
533
534 // Require both commits are present
535 if !pvpLobbies.Has(lobbyId+":commit:"+creator) || !pvpLobbies.Has(lobbyId+":commit:"+challenger) {
536 panic("both players must commit moves before revealing")
537 }
538
539 pvpLobbies.Set(lobbyId+":reveal:"+caller, choice+":"+salt)
540 pvpLobbies.Set(lobbyId+":status", "revealed")
541}
542
543// ClaimPvpTimeout allows a lobby participant to claim a timeout win.
544// Only the creator or challenger may call this.
545func ClaimPvpTimeout(cur realm, lobbyId string) {
546 requireNotPaused()
547 caller := cur.Previous().Address().String()
548
549 if !pvpLobbies.Has(lobbyId) {
550 panic("lobby not found")
551 }
552 lobbyJson := pvpLobbies.Get(lobbyId).(string)
553
554 // Extract creator address
555 creatorStart := strings.Index(lobbyJson, `"creator":"`) + len(`"creator":"`)
556 creatorEnd := strings.Index(lobbyJson[creatorStart:], `"`) + creatorStart
557 creator := lobbyJson[creatorStart:creatorEnd]
558
559 // Get challenger (may not exist yet)
560 challenger := ""
561 if pvpLobbies.Has(lobbyId + ":challenger") {
562 challenger = pvpLobbies.Get(lobbyId + ":challenger").(string)
563 }
564
565 // Verify caller is a participant
566 if caller != creator && caller != challenger {
567 panic("not a lobby participant")
568 }
569
570 // Require minimum 10 minutes since lobby creation
571 createdAtStart := strings.Index(lobbyJson, `"createdAt":`) + len(`"createdAt":`)
572 createdAtEnd := strings.IndexAny(lobbyJson[createdAtStart:], `,}`) + createdAtStart
573 createdAt, _ := strconv.ParseInt(lobbyJson[createdAtStart:createdAtEnd], 10, 64)
574 if time.Now().Unix()-createdAt < 600 {
575 panic("timeout not yet claimable — minimum 10 minutes must elapse")
576 }
577
578 pvpLobbies.Set(lobbyId+":winner", caller)
579 pvpLobbies.Set(lobbyId+":status", "finished")
580}
581
582func ExpireLobbies(cur realm) {
583 if !isAdmin(cur) {
584 panic("unauthorized")
585 }
586}
587
588// ── Tools ────────────────────────────────────────────────────────────────────
589
590func BuyTool(cur realm, toolId string) {
591 requireNotPaused()
592 caller := cur.Previous().Address().String()
593 if isBlacklisted(caller) {
594 panic("address banned")
595 }
596
597 // Look up cost
598 costs, hasCost := toolCosts[toolId]
599 if !hasCost && toolId != "hands" {
600 panic("unknown tool: " + toolId)
601 }
602
603 // Deduct balance (hands is free, others require coins)
604 if hasCost && len(costs) > 0 {
605 deductBalances(caller, costs)
606 }
607
608 // Grant tool
609 existing := ""
610 if ownedTools.Has(caller) {
611 existing = ownedTools.Get(caller).(string)
612 }
613 if existing == "" {
614 ownedTools.Set(caller, toolId)
615 } else {
616 ownedTools.Set(caller, existing+","+toolId)
617 }
618}
619
620func RepairTool(cur realm, toolId string) {
621 requireNotPaused()
622 caller := cur.Previous().Address().String()
623 if isBlacklisted(caller) {
624 panic("address banned")
625 }
626
627 maxLife, hasLife := toolMaxLife[toolId]
628 if !hasLife || maxLife == 0 {
629 panic("tool cannot be repaired: " + toolId)
630 }
631
632 // Cost = 30% of craft cost (rounded up per coin)
633 baseCosts, hasCost := toolCosts[toolId]
634 if !hasCost {
635 panic("unknown tool: " + toolId)
636 }
637 var repairCosts [][2]string
638 for _, pair := range baseCosts {
639 coinId, amtStr := pair[0], pair[1]
640 baseAmt, _ := strconv.ParseUint(amtStr, 10, 64)
641 // 30% rounded up: (base * 30 + 99) / 100
642 repairAmt := (baseAmt*30 + 99) / 100
643 if repairAmt > 0 {
644 repairCosts = append(repairCosts, [2]string{coinId, strconv.FormatUint(repairAmt, 10)})
645 }
646 }
647
648 deductBalances(caller, repairCosts)
649
650 // Restore full durability
651 toolLife.Set(caller+":"+toolId, strconv.FormatUint(maxLife, 10))
652}
653
654// ── Progression ──────────────────────────────────────────────────────────────
655
656func TrainSkill(cur realm, skillId string) {
657 requireNotPaused()
658 caller := cur.Previous().Address().String()
659 if isBlacklisted(caller) {
660 panic("address banned")
661 }
662 key := caller + ":" + skillId
663
664 // Look up current rank
665 rank := uint64(0)
666 if skillRanks.Has(key) {
667 rank, _ = strconv.ParseUint(skillRanks.Get(key).(string), 10, 64)
668 }
669
670 // Check rank cap
671 maxRank, hasMax := skillMaxRanks[skillId]
672 if !hasMax {
673 panic("unknown skill: " + skillId)
674 }
675 if rank >= maxRank {
676 panic("skill already at maximum rank")
677 }
678
679 // Compute cost: base × 2^rank
680 baseCosts, hasCost := skillBaseCosts[skillId]
681 if !hasCost {
682 panic("unknown skill: " + skillId)
683 }
684 var scaledCosts [][2]string
685 multiplier := uint64(1)
686 for i := uint64(0); i < rank; i++ {
687 multiplier *= 2
688 }
689 for _, pair := range baseCosts {
690 coinId, amtStr := pair[0], pair[1]
691 baseAmt, _ := strconv.ParseUint(amtStr, 10, 64)
692 scaled := baseAmt * multiplier
693 if scaled > 0 {
694 scaledCosts = append(scaledCosts, [2]string{coinId, strconv.FormatUint(scaled, 10)})
695 }
696 }
697
698 deductBalances(caller, scaledCosts)
699
700 // Increment rank
701 skillRanks.Set(key, strconv.FormatUint(rank+1, 10))
702}
703
704func ClaimAchievement(cur realm, achievementId string) {
705 requireNotPaused()
706 caller := cur.Previous().Address().String()
707 key := caller + ":" + achievementId
708 if achievements.Has(key) {
709 panic("already claimed")
710 }
711
712 // Require that the caller has actually played (mined at least 1 coin lifetime)
713 if !miners.Has(caller) {
714 panic("no mining activity — play the game first")
715 }
716 total, _ := strconv.ParseUint(miners.Get(caller).(string), 10, 64)
717 if total == 0 {
718 panic("no mining activity — play the game first")
719 }
720
721 achievements.Set(key, "1")
722}
723
724func DailyCheckin(cur realm) {
725 requireNotPaused()
726 caller := cur.Previous().Address().String()
727 now := time.Now().Unix()
728 if dailyCheckins.Has(caller) {
729 last, _ := strconv.ParseInt(dailyCheckins.Get(caller).(string), 10, 64)
730 if now-last < 72000 {
731 panic("too soon for daily check-in")
732 }
733 }
734 dailyCheckins.Set(caller, strconv.FormatInt(now, 10))
735}
736
737// CreateTask registers a new task on-chain.
738func CreateTask(cur realm, taskId string, desc string) {
739 requireNotPaused()
740 caller := cur.Previous().Address().String()
741 // Store task metadata keyed by taskId; only the creator can create it
742 achievements.Set("task:"+taskId+":creator", caller)
743 achievements.Set("task:"+taskId+":desc", desc)
744}
745
746func CompleteTask(cur realm, taskId string) {
747 requireNotPaused()
748 caller := cur.Previous().Address().String()
749 achievements.Set(caller+":"+taskId, "task:1")
750}
751
752func RegisterVein(cur realm, coinId string, maxSupply string) {
753 if !isAdmin(cur) {
754 panic("unauthorized")
755 }
756 veinSupply.Set(coinId, maxSupply+":"+maxSupply)
757}
758
759// ── Queries (pure reads — no cur realm needed) ────────────────────────────────
760
761func PendingMints(playerAddr string) string {
762 var parts []string
763 pendingMints.Iterate(playerAddr+":", playerAddr+":\xFF", func(key string, val any) bool {
764 coinId := strings.TrimPrefix(key, playerAddr+":")
765 parts = append(parts, `["`+coinId+`","`+val.(string)+`"]`)
766 return false
767 })
768 return ufmt.Sprintf(`{"pending":[%s]}`, strings.Join(parts, ","))
769}
770
771func AllBalances(playerAddr string) string {
772 var parts []string
773 balances.Iterate(playerAddr+":", playerAddr+":\xFF", func(key string, val any) bool {
774 coinId := strings.TrimPrefix(key, playerAddr+":")
775 parts = append(parts, `"`+coinId+`":`+val.(string))
776 return false
777 })
778 return "{" + strings.Join(parts, ",") + "}"
779}
780
781func VeinSupply(coinId string) string {
782 if !veinSupply.Has(coinId) {
783 return `{"remaining":"0","max":"0"}`
784 }
785 p := strings.SplitN(veinSupply.Get(coinId).(string), ":", 2)
786 if len(p) != 2 {
787 return `{"remaining":"0","max":"0"}`
788 }
789 return `{"remaining":"` + p[0] + `","max":"` + p[1] + `"}`
790}
791
792func CasinoRound(roundId string) string {
793 if !casinoRounds.Has(roundId) {
794 return "null"
795 }
796 return casinoRounds.Get(roundId).(string)
797}
798
799func CasinoHistory(_ string) string { return "[]" }
800
801func PvpLobby(lobbyId string) string {
802 if !pvpLobbies.Has(lobbyId) {
803 return "null"
804 }
805 return pvpLobbies.Get(lobbyId).(string)
806}
807
808func OpenLobbies() string {
809 var parts []string
810 pvpLobbies.Iterate("pvp-", "pvp-\xFF", func(key string, val any) bool {
811 if !strings.Contains(key, ":") {
812 parts = append(parts, val.(string))
813 }
814 return false
815 })
816 return "[" + strings.Join(parts, ",") + "]"
817}
818
819func JackpotPool(coinId string) string {
820 return `{"pool":"0","coinId":"` + coinId + `"}`
821}
822
823func PlayerProfile(playerAddr string) string {
824 return `{"address":"` + playerAddr + `"}`
825}
826
827func OwnedTools(playerAddr string) string {
828 if !ownedTools.Has(playerAddr) {
829 return `["hands"]`
830 }
831 ids := strings.Split(ownedTools.Get(playerAddr).(string), ",")
832 var quoted []string
833 for _, id := range ids {
834 quoted = append(quoted, `"`+id+`"`)
835 }
836 return "[" + strings.Join(quoted, ",") + "]"
837}
838
839func LifetimeMined(playerAddr string) string {
840 var parts []string
841 lifetimeMined.Iterate(playerAddr+":", playerAddr+":\xFF", func(key string, val any) bool {
842 coinId := strings.TrimPrefix(key, playerAddr+":")
843 parts = append(parts, `"`+coinId+`":`+val.(string))
844 return false
845 })
846 return "{" + strings.Join(parts, ",") + "}"
847}
848
849func AllMiners(limit int, offset int) string {
850 var parts []string
851 count, skipped := 0, 0
852 miners.Iterate("", "\xFF", func(key string, val any) bool {
853 if skipped < offset {
854 skipped++
855 return false
856 }
857 if count >= limit {
858 return true
859 }
860 display := key
861 if len(display) > 12 {
862 display = display[:8] + "..."
863 }
864 parts = append(parts, `{"address":"`+key+`","display":"`+display+`","total":`+val.(string)+`}`)
865 count++
866 return false
867 })
868 return "[" + strings.Join(parts, ",") + "]"
869}
870
871func GetMedals(playerAddr string) string {
872 var claimed []string
873 achievements.Iterate(playerAddr+":", playerAddr+":\xFF", func(key string, val any) bool {
874 achId := strings.TrimPrefix(key, playerAddr+":")
875 claimed = append(claimed, `"`+achId+`"`)
876 return false
877 })
878 return ufmt.Sprintf(`{"medals":[%s]}`, strings.Join(claimed, ","))
879}
880
881func DailyCheckinStatus(playerAddr string) string {
882 now := time.Now().Unix()
883 last := int64(0)
884 if dailyCheckins.Has(playerAddr) {
885 last, _ = strconv.ParseInt(dailyCheckins.Get(playerAddr).(string), 10, 64)
886 }
887 done := now-last < 72000
888 return ufmt.Sprintf(`{"ok":true,"done":%t,"streak":1,"next":%d}`, done, last+86400)
889}
890
891func ListTasks(_ string) string { return `{"ok":true,"tasks":[]}` }
892
893func SkillRanks(playerAddr string) string {
894 var parts []string
895 skillRanks.Iterate(playerAddr+":", playerAddr+":\xFF", func(key string, val any) bool {
896 skillId := strings.TrimPrefix(key, playerAddr+":")
897 parts = append(parts, `"`+skillId+`":`+val.(string))
898 return false
899 })
900 return "{" + strings.Join(parts, ",") + "}"
901}
902
903func ToolLife(playerAddr string) string {
904 var parts []string
905 toolLife.Iterate(playerAddr+":", playerAddr+":\xFF", func(key string, val any) bool {
906 toolId := strings.TrimPrefix(key, playerAddr+":")
907 parts = append(parts, `"`+toolId+`":`+val.(string))
908 return false
909 })
910 return "{" + strings.Join(parts, ",") + "}"
911}
912
913// ── Render ───────────────────────────────────────────────────────────────────
914
915func Render(_ string) string {
916 return ufmt.Sprintf("# GnoMoji Game Realm\n\nAdmin: `%s`\n\nPath: `gno.land/r/g138gdmwj6heduu3st796lw6qqdwylgaxf7ty5gz/game6`\n\nMine emoji coins on Gno.land!\n", admin.String())
917}
918
919// ── Helpers ──────────────────────────────────────────────────────────────────
920
921// deductBalances atomically checks all balances then deducts them.
922// Panics if any balance is insufficient — no partial deductions.
923// costs is a slice of [coinId, amount] pairs.
924func deductBalances(caller string, costs [][2]string) {
925 // First pass: verify all sufficient
926 for _, pair := range costs {
927 coinId, amtStr := pair[0], pair[1]
928 required, err := strconv.ParseUint(amtStr, 10, 64)
929 if err != nil || required == 0 {
930 continue
931 }
932 balKey := caller + ":" + coinId
933 have := uint64(0)
934 if balances.Has(balKey) {
935 have, _ = strconv.ParseUint(balances.Get(balKey).(string), 10, 64)
936 }
937 if have < required {
938 panic("insufficient " + coinId + ": need " + amtStr + " have " + strconv.FormatUint(have, 10))
939 }
940 }
941 // Second pass: deduct all
942 for _, pair := range costs {
943 coinId, amtStr := pair[0], pair[1]
944 required, err := strconv.ParseUint(amtStr, 10, 64)
945 if err != nil || required == 0 {
946 continue
947 }
948 balKey := caller + ":" + coinId
949 current, _ := strconv.ParseUint(balances.Get(balKey).(string), 10, 64)
950 balances.Set(balKey, strconv.FormatUint(current-required, 10))
951 }
952}
953
954func splitJsonArray(s string) []string {
955 var result []string
956 depth, start := 0, 0
957 for i := 0; i < len(s); i++ {
958 switch s[i] {
959 case '[', '{':
960 depth++
961 case ']', '}':
962 depth--
963 case ',':
964 if depth == 0 {
965 if e := strings.TrimSpace(s[start:i]); e != "" {
966 result = append(result, e)
967 }
968 start = i + 1
969 }
970 }
971 }
972 if e := strings.TrimSpace(s[start:]); e != "" {
973 result = append(result, e)
974 }
975 return result
976}