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