game.gno
57.98 Kb · 1584 lines
1// GnoMoji — Main Game Realm
2// gno.land/r/g138gdmwj6heduu3st796lw6qqdwylgaxf7ty5gz/game10
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// Phase 4: ClaimMine cooldown 900s; per-entry coin cap 100.
12// Phase 5: Expanded tool costs (up to 24 ingredients); 48 new skill branches (nature/food/people/things/travel/signs).
13// Phase 6: PvP wager escrow + prize payout; casino jackpot server-side; ClaimAndMint treasury fix.
14// Phase 7: Guild system — CreateGuild, JoinGuild, LeaveGuild, PlayerGuild, GuildInfo, TopGuilds; guild yield bonus.
15
16package game10
17
18import (
19 "strconv"
20 "strings"
21 "time"
22
23 "gno.land/p/nt/avl/v0"
24 "gno.land/p/nt/ufmt/v0"
25)
26
27// ── State ────────────────────────────────────────────────────────────────────
28
29var (
30 admin address
31 minterAddr address // authorized minter realm (minter5) — set via SetMinter
32 pendingMints avl.Tree // addr+":"+coinId → amount string
33 balances avl.Tree // addr+":"+coinId → amount string
34 veinSupply avl.Tree // coinId → "remaining:max"
35 casinoRounds avl.Tree // roundId → JSON string
36 pvpLobbies avl.Tree // lobbyId → JSON string
37 pvpWagers avl.Tree // lobbyId → "coinId:amount:creator:challenger" (challenger="" until joined)
38 ownedTools avl.Tree // addr → comma-separated tool IDs
39 toolLife avl.Tree // addr+":"+toolId → remaining life string
40 lifetimeMined avl.Tree // addr+":"+coinId → total mined string
41 achievements avl.Tree // addr+":"+achId → "1"
42 dailyCheckins avl.Tree // addr → unix seconds string
43 skillRanks avl.Tree // addr+":"+skillId → rank string
44 miners avl.Tree // addr → total mined all-time string
45 lastClaim avl.Tree // addr → unix timestamp of last ClaimMine/ClaimAndMint (velocity limit)
46 blacklist avl.Tree // addr → "reason:timestamp"
47 banExpiries avl.Tree // addr → unix expiry (0 = permanent)
48 guilds avl.Tree // guildId → JSON{name, leader, memberCount, createdAt}
49 guildMembers avl.Tree // guildId+":"+addr → "1"
50 playerGuild avl.Tree // addr → guildId
51 paused bool
52)
53
54// ── Cost Tables ───────────────────────────────────────────────────────────────
55
56// toolCosts maps toolId → slice of [coinId, amount] pairs.
57// Copied from catalog.ts TOOLS array. Amounts are for qty=1.
58// Phase 5: expanded tool costs — each tool requires more ingredient types at higher tiers.
59// Matches catalog.ts TOOLS array exactly.
60var toolCosts = map[string][][2]string{
61 "twig": {
62 {"grin", "120"}, {"joy", "75"}, {"cool", "60"},
63 },
64 "wood": {
65 {"seedling", "180"}, {"leaves", "150"}, {"fallen", "120"}, {"think", "105"}, {"sleep", "90"}, {"party", "75"},
66 },
67 "stone": {
68 {"rock", "600"}, {"log", "540"}, {"evergreen", "480"}, {"brick", "420"}, {"mushroom", "360"}, {"heart", "300"},
69 {"deciduous", "240"}, {"cactus", "180"},
70 },
71 "iron": {
72 {"gear", "840"}, {"chains", "780"}, {"wrench", "720"}, {"hammer", "660"}, {"anchor", "630"}, {"pick", "570"},
73 {"nut", "400"}, {"saw", "200"}, {"hammer-wrench", "300"}, {"axe", "60"},
74 },
75 "gold": {
76 {"gold-medal", "1140"}, {"crown", "1080"}, {"trophy", "1020"}, {"money-bag", "960"}, {"bolt", "930"}, {"sword", "870"},
77 {"old-key", "80"}, {"scale", "60"}, {"bullseye", "200"}, {"mahjong", "50"}, {"slots", "20"}, {"joker", "15"},
78 },
79 "diamond": {
80 {"gem", "1560"}, {"coin", "1440"}, {"ring", "1380"}, {"disco", "1320"}, {"kite", "1260"}, {"dodo", "1140"},
81 {"scope", "25"}, {"telescope", "40"}, {"tube", "60"}, {"dish", "50"},
82 {"lock", "300"}, {"key-lock", "200"},
83 {"eight", "150"}, {"puzzle", "200"},
84 },
85 "cosmic": {
86 {"galaxy", "1950"}, {"moai", "1860"}, {"hamsa", "1800"}, {"mirror", "1740"}, {"atom", "1650"}, {"ufo", "1500"},
87 {"shooting", "15"}, {"rex", "20"}, {"mammoth", "25"}, {"new-face", "20"},
88 {"cyclone", "40"}, {"tornado", "50"},
89 {"sat", "60"}, {"castle", "80"}, {"liberty", "100"}, {"kaaba", "10"},
90 },
91 "plasma": {
92 {"galaxy", "2250"}, {"atom", "2100"}, {"moai", "2100"}, {"comet", "1950"}, {"jelly", "1800"}, {"peacock", "1800"},
93 {"om", "8"}, {"yin", "10"}, {"ophiuchus", "12"}, {"fleur", "15"}, {"trident", "15"},
94 {"radioactive", "40"}, {"biohazard", "40"},
95 {"castle-jp", "60"},
96 {"sun-face", "20"}, {"dragon-face", "25"}, {"sauropod", "30"}, {"moon-word", "80"},
97 },
98 "neutron": {
99 {"jelly", "2850"}, {"peacock", "2700"}, {"unicorn", "2550"}, {"galaxy", "2400"}, {"hamsa", "2400"}, {"dna", "2100"},
100 {"genie", "8"}, {"heart-organ", "40"}, {"mage", "50"}, {"monarch", "60"},
101 {"ninja", "40"}, {"brain", "30"}, {"lotus-sit", "40"},
102 {"lion", "60"}, {"tiger", "80"}, {"eagle", "80"}, {"wolf", "100"}, {"shark", "120"},
103 },
104 "quasar": {
105 {"unicorn", "3450"}, {"peacock", "3300"}, {"jelly", "3300"}, {"lotus", "2550"}, {"galaxy", "2850"}, {"dragon", "2550"},
106 {"fireworks", "30"}, {"medal", "25"}, {"sports-medal", "40"}, {"silver-medal", "50"}, {"bronze-medal", "60"},
107 {"tower", "80"}, {"hot-spring", "100"}, {"pirate", "60"},
108 {"fairy", "80"}, {"vampire", "80"},
109 {"heli", "40"}, {"bullet", "50"}, {"race", "80"},
110 },
111 "void": {
112 {"lotus", "3600"}, {"unicorn", "4350"}, {"dragon", "4200"}, {"peacock", "4350"}, {"jelly", "4500"}, {"galaxy", "1500"},
113 {"om", "15"}, {"yin", "15"}, {"trident", "20"}, {"fleur", "20"}, {"ophiuchus", "18"},
114 {"shooting", "25"}, {"sun-face", "30"}, {"dragon-face", "40"}, {"rex", "35"}, {"sauropod", "40"},
115 {"mammoth", "45"}, {"new-face", "35"},
116 {"kaaba", "18"},
117 {"witness", "12"}, {"genie", "12"},
118 },
119 "genesis": {
120 {"lotus", "5400"}, {"dragon", "5400"}, {"unicorn", "5250"}, {"peacock", "4950"}, {"jelly", "4800"}, {"infinity", "4200"},
121 {"genie", "12"}, {"witness", "15"},
122 {"sun-face", "40"}, {"shooting", "30"}, {"dragon-face", "50"},
123 {"om", "20"}, {"yin", "20"}, {"trident", "25"}, {"fleur", "25"},
124 {"kaaba", "20"},
125 {"rex", "40"}, {"sauropod", "45"}, {"mammoth", "50"},
126 {"new-face", "40"},
127 {"lion", "80"}, {"shark", "150"},
128 },
129 "omega": {
130 {"lotus", "6900"}, {"dragon", "6600"}, {"unicorn", "6300"}, {"peacock", "6000"}, {"jelly", "6000"}, {"infinity", "5700"},
131 {"genie", "18"},
132 {"witness", "20"},
133 {"sun-face", "50"},
134 {"shooting", "40"},
135 {"dragon-face", "60"},
136 {"om", "25"},
137 {"yin", "25"},
138 {"trident", "30"},
139 {"fleur", "30"},
140 {"ophiuchus", "25"},
141 {"kaaba", "25"},
142 {"rex", "50"},
143 {"sauropod", "55"},
144 {"mammoth", "60"},
145 {"new-face", "50"},
146 {"mage", "80"},
147 {"heart-organ", "60"},
148 {"sat", "100"},
149 },
150}
151
152// skillBaseCosts maps skillId → base cost at rank 0.
153// Actual cost at rank N = base × 2^N (doubles per rank).
154var skillBaseCosts = map[string][][2]string{
155 // STRIKE branch
156 "wrist": {{"grin", "80"}, {"joy", "20"}},
157 "palm_strike": {{"cool", "60"}, {"think", "40"}},
158 "follow": {{"fire", "40"}, {"up", "40"}},
159 "knuckle_wrap": {{"rock", "45"}, {"log", "40"}},
160 "heavy_swing": {{"brick", "35"}, {"evergreen", "30"}},
161 "stone_breaker": {{"rock", "70"}, {"hammer", "25"}, {"gear", "20"}},
162 "anvil_force": {{"chains", "35"}, {"wrench", "30"}, {"brick", "60"}},
163 "point_drive": {{"pick", "35"}, {"gear", "40"}, {"log", "90"}},
164 "kinetic_thrust": {{"rocket", "20"}, {"bolt", "12"}, {"fire", "70"}},
165 "crushing_blow": {{"crown", "10"}, {"trophy", "12"}, {"hammer", "45"}, {"bolt", "15"}},
166 "shatter_spike": {{"sword", "18"}, {"pick", "45"}, {"chains", "45"}, {"gold-medal", "12"}},
167 "ironclad_cleave": {{"anchor", "40"}, {"gear", "60"}, {"wrench", "50"}, {"money-bag", "15"}},
168 "thunder_jab": {{"bolt", "25"}, {"rocket", "30"}, {"fire", "120"}, {"wand", "15"}},
169 "titan_hammer": {{"gem", "14"}, {"coin", "18"}, {"crown", "22"}, {"trophy", "22"}, {"sword", "28"}},
170 "diamond_cleave": {{"gem", "20"}, {"ring", "12"}, {"disco", "15"}, {"pick", "70"}, {"gold-medal", "24"}},
171 "hyper_drive": {{"kite", "15"}, {"dodo", "12"}, {"bolt", "35"}, {"rocket", "45"}, {"gear", "70"}},
172 "quasar_impact": {{"galaxy", "18"}, {"moai", "18"}, {"atom", "20"}, {"ufo", "16"}, {"comet", "18"}},
173 "black_hole_crush": {{"galaxy", "30"}, {"atom", "28"}, {"moai", "24"}, {"gem", "35"}, {"ring", "28"}, {"comet", "22"}},
174 "mythic_annihilation": {{"dragon", "8"}, {"unicorn", "10"}, {"jelly", "12"}, {"peacock", "12"}, {"galaxy", "35"}, {"moai", "30"}},
175 "singularity_fist": {{"infinity", "6"}, {"lotus", "8"}, {"dragon", "10"}, {"unicorn", "12"}, {"jelly", "14"}, {"galaxy", "40"}},
176 // FORTUNE branch
177 "fortune": {{"clover", "16"}, {"heart", "40"}},
178 "four_leaf": {{"seedling", "60"}, {"leaves", "50"}},
179 "edge": {{"pizza", "20"}, {"spark", "30"}},
180 "glint": {{"spark", "50"}, {"star", "12"}},
181 "spark_chaser": {{"fire", "45"}, {"tada", "25"}},
182 "razor_focus": {{"coffee", "25"}, {"game", "25"}, {"hundred", "30"}},
183 "precision_cut": {{"eyes", "40"}, {"skull", "35"}, {"flutter", "18"}},
184 "aurora_gaze": {{"rainbow", "15"}, {"spark", "60"}, {"moon", "20"}},
185 "starlight_gleam": {{"star", "25"}, {"clover", "20"}, {"heart", "80"}},
186 "double_haul": {{"gear", "30"}, {"anchor", "20"}, {"up", "70"}},
187 "twin_strike": {{"chains", "25"}, {"wrench", "25"}, {"pick", "20"}},
188 "executioner": {{"skull", "60"}, {"sword", "18"}, {"crown", "10"}, {"orb", "8"}},
189 "celestial_sight": {{"planet", "10"}, {"moon", "30"}, {"star", "30"}, {"bead", "8"}},
190 "helix_harmony": {{"dna", "12"}, {"spark", "80"}, {"rainbow", "25"}, {"orb", "10"}},
191 "fortune_split": {{"money-bag", "15"}, {"gold-medal", "12"}, {"clover", "35"}, {"chains", "40"}},
192 "critical_surge": {{"gem", "15"}, {"disco", "14"}, {"ring", "12"}, {"orb", "20"}, {"planet", "18"}},
193 "astral_scintillation": {{"ring", "14"}, {"gem", "16"}, {"dna", "20"}, {"star", "60"}, {"moon", "50"}},
194 "plunder_frenzy": {{"coin", "25"}, {"money-bag", "30"}, {"gem", "12"}, {"crown", "25"}, {"trophy", "25"}},
195 "singularity_edge": {{"lotus", "6"}, {"dragon", "8"}, {"galaxy", "25"}, {"hamsa", "20"}, {"atom", "22"}, {"mirror", "20"}},
196 "cosmic_fortune": {{"peacock", "10"}, {"unicorn", "8"}, {"comet", "25"}, {"disco", "30"}, {"galaxy", "30"}, {"gem", "35"}},
197 // POCKET branch
198 "dustbag": {{"grin", "200"}, {"sleep", "40"}},
199 "rhythm": {{"music", "16"}, {"clap", "30"}},
200 "pocket_expansion": {{"fallen", "45"}, {"herb", "35"}},
201 "steady_tempo": {{"party", "40"}, {"wow", "30"}},
202 "cadence": {{"music", "30"}, {"coffee", "20"}, {"clap", "50"}},
203 "deep_pouch": {{"log", "70"}, {"rock", "60"}, {"mushroom", "35"}},
204 "vein_resonator": {{"flutter", "20"}, {"rainbow", "15"}, {"tada", "40"}},
205 "cart_bed": {{"log", "120"}, {"rock", "100"}, {"brick", "80"}, {"money-bag", "10"}},
206 "bounty_overflow": {{"trophy", "15"}, {"crown", "12"}, {"pick", "45"}, {"game", "35"}},
207 "fracture_pulse": {{"bolt", "25"}, {"wand", "15"}, {"hammer", "50"}, {"rocket", "25"}},
208 "metro_pulse": {{"music", "45"}, {"coffee", "35"}, {"raise", "30"}, {"bolt", "15"}},
209 "hoarders_tote": {{"coin", "22"}, {"gem", "10"}, {"money-bag", "35"}, {"rock", "150"}, {"log", "150"}},
210 "shatter_strike": {{"gem", "14"}, {"bolt", "35"}, {"wand", "25"}, {"sword", "30"}, {"hammer", "60"}},
211 "super_conductor": {{"coin", "20"}, {"ring", "12"}, {"music", "60"}, {"bolt", "30"}, {"coffee", "50"}},
212 "dodo_instinct": {{"dodo", "14"}, {"kite", "14"}, {"crown", "22"}, {"trophy", "22"}, {"eyes", "80"}},
213 "void_rend": {{"infinity", "6"}, {"jelly", "12"}, {"dragon", "8"}, {"atom", "30"}, {"comet", "25"}, {"bolt", "60"}},
214 "celestial_duplication": {{"infinity", "5"}, {"lotus", "6"}, {"dragon", "8"}, {"peacock", "10"}, {"coin", "40"}, {"gem", "35"}},
215 "eternity_flow": {{"infinity", "5"}, {"lotus", "6"}, {"peacock", "8"}, {"hamsa", "25"}, {"mirror", "25"}, {"music", "100"}},
216 "abyssal_reserve": {{"unicorn", "6"}, {"jelly", "10"}, {"moai", "25"}, {"ufo", "25"}, {"money-bag", "50"}, {"rock", "300"}},
217 "singularity_snap": {{"infinity", "8"}, {"lotus", "8"}, {"dragon", "10"}, {"galaxy", "40"}, {"moai", "30"}, {"bolt", "80"}},
218
219 // ── Phase 5: Nature branch (level 20) ────────────────────────────────────
220 "deep_root": {{"bear", "60"}, {"fox", "40"}},
221 "stone_sense": {{"turtle", "30"}, {"coral", "15"}},
222 "bloom_tide": {{"sakura", "20"}, {"bee", "50"}, {"deciduous", "80"}},
223 "thorn_hide": {{"boar", "40"}, {"snake", "30"}, {"scorpion", "20"}},
224 "old_growth": {{"elephant", "25"}, {"gorilla", "20"}, {"whale-spout", "15"}},
225 "storm_surge": {{"storm", "25"}, {"lightning-cloud", "20"}, {"tornado", "15"}},
226 "predator_sense": {{"tiger", "20"}, {"lion", "20"}, {"wolf", "20"}, {"eagle", "15"}},
227 "world_tree": {{"rex", "12"}, {"mammoth", "10"}, {"dragon-face", "8"}, {"shooting", "8"}},
228
229 // ── Phase 5: Food branch (level 30) ──────────────────────────────────────
230 "hot_meal": {{"ramen", "50"}, {"stew", "40"}},
231 "sugar_rush": {{"candy", "60"}, {"cupcake", "40"}, {"donut", "30"}},
232 "second_wind": {{"burger", "50"}, {"bacon", "40"}, {"cheese", "30"}},
233 "caffeinated": {{"boba", "40"}, {"tea", "30"}, {"sake", "25"}},
234 "feast_mode": {{"sushi", "30"}, {"curry", "40"}, {"pasta", "40"}, {"paella", "25"}},
235 "night_shift": {{"honey", "30"}, {"pie", "25"}, {"cake", "20"}},
236 "brew_mastery": {{"beer", "40"}, {"wine", "30"}, {"cocktail", "20"}, {"champagne", "15"}},
237 "mythic_feast": {{"tropical-drink", "20"}, {"cheers", "15"}, {"toast", "12"}, {"mate", "12"}},
238
239 // ── Phase 5: People branch (level 40) ────────────────────────────────────
240 "mentor": {{"baby", "60"}, {"child", "50"}},
241 "crowd_favor": {{"clap", "80"}, {"heart-hands", "60"}},
242 "team_spirit": {{"shake", "40"}, {"pray", "30"}, {"hug-pair", "30"}},
243 "athlete_heart": {{"surf", "30"}, {"swim", "30"}, {"run", "40"}, {"lift", "25"}},
244 "warrior_code": {{"ninja", "20"}, {"fencer", "15"}, {"juggle", "15"}},
245 "elder_wisdom": {{"monarch", "15"}, {"mage", "15"}, {"fairy", "12"}},
246 "hero_legend": {{"hero", "12"}, {"vampire", "10"}, {"merfolk", "10"}},
247 "genie_wish": {{"genie", "8"}, {"heart-organ", "12"}},
248
249 // ── Phase 5: Things branch (level 50) ────────────────────────────────────
250 "tempered_grip": {{"gloves", "80"}, {"hammer", "60"}},
251 "field_repair": {{"hammer-wrench", "40"}, {"saw", "30"}, {"nut", "50"}},
252 "master_craft": {{"wrench", "60"}, {"driver", "50"}, {"clamp", "30"}},
253 "workshop_lore": {{"toolbox", "30"}, {"abacus", "25"}, {"scroll", "20"}},
254 "iron_discipline": {{"axe", "30"}, {"dagger", "20"}, {"bow", "15"}},
255 "vault_keeper": {{"lock", "40"}, {"key-lock", "30"}, {"old-key", "15"}},
256 "precision_forge": {{"scope", "20"}, {"telescope", "15"}, {"tube", "18"}, {"dish", "15"}},
257 "deep_science": {{"alembic", "12"}, {"shield", "15"}, {"hammer-pick", "15"}, {"scale", "12"}},
258
259 // ── Phase 5: Travel branch (level 65) ────────────────────────────────────
260 "long_haul": {{"car", "80"}, {"bus", "60"}},
261 "vein_compass": {{"plane", "50"}, {"sail", "30"}},
262 "nomad_pace": {{"moto", "40"}, {"cycle", "50"}, {"scooter", "40"}},
263 "deep_sea": {{"ship", "30"}, {"whale-spout", "15"}, {"dolphin", "25"}},
264 "sky_route": {{"rocket", "30"}, {"heli", "20"}, {"depart", "20"}},
265 "monument_seal": {{"tower", "20"}, {"castle", "15"}, {"liberty", "12"}},
266 "orbital_drift": {{"sat", "15"}, {"castle-jp", "12"}},
267 "sacred_ground": {{"kaaba", "8"}, {"shrine", "10"}, {"temple", "10"}, {"mosque", "10"}},
268
269 // ── Phase 5: Signs branch (level 80) ─────────────────────────────────────
270 "amplify": {{"sparkle", "80"}, {"eight-star", "60"}},
271 "resonance": {{"recycle", "60"}, {"medical", "50"}},
272 "sigil_power": {{"worship", "40"}, {"peace-sign", "30"}},
273 "zodiac_edge": {{"aries", "20"}, {"taurus", "20"}, {"gemini", "20"}, {"cancer", "15"}},
274 "star_david": {{"star-david", "15"}, {"dharma", "15"}, {"cross", "15"}, {"menorah", "12"}},
275 "hazard_forge": {{"radioactive", "12"}, {"biohazard", "12"}},
276 "cosmic_sigil": {{"trident", "10"}, {"fleur", "10"}, {"ophiuchus", "8"}},
277 "infinite_mark": {{"om", "8"}, {"yin", "8"}, {"witness", "6"}},
278}
279
280// skillMaxRanks maps skillId → maximum rank allowed.
281// Most skills cap at 3 or 4 — see catalog.ts SKILLS[*].max.
282var skillMaxRanks = map[string]uint64{
283 // Strike branch
284 "wrist": 5, "palm_strike": 4, "follow": 3, "knuckle_wrap": 4, "heavy_swing": 4,
285 "stone_breaker": 4, "anvil_force": 4, "point_drive": 4, "kinetic_thrust": 4,
286 "crushing_blow": 4, "shatter_spike": 4, "ironclad_cleave": 3, "thunder_jab": 3,
287 "titan_hammer": 3, "diamond_cleave": 3, "hyper_drive": 3, "quasar_impact": 3,
288 "black_hole_crush": 3, "mythic_annihilation": 3, "singularity_fist": 3,
289 // Fortune branch
290 "fortune": 4, "four_leaf": 3, "edge": 3, "glint": 3, "spark_chaser": 3,
291 "razor_focus": 3, "precision_cut": 3, "aurora_gaze": 3, "starlight_gleam": 3,
292 "double_haul": 3, "twin_strike": 3, "executioner": 3, "celestial_sight": 3,
293 "helix_harmony": 3, "fortune_split": 3, "critical_surge": 3,
294 "astral_scintillation": 3, "plunder_frenzy": 3, "singularity_edge": 3, "cosmic_fortune": 3,
295 // Pocket branch
296 "dustbag": 3, "rhythm": 3, "pocket_expansion": 3, "steady_tempo": 3, "cadence": 3,
297 "deep_pouch": 3, "vein_resonator": 3, "cart_bed": 3, "bounty_overflow": 3,
298 "fracture_pulse": 3, "metro_pulse": 3, "hoarders_tote": 3, "shatter_strike": 3,
299 "super_conductor": 3, "dodo_instinct": 3, "void_rend": 3, "celestial_duplication": 3,
300 "eternity_flow": 3, "abyssal_reserve": 3, "singularity_snap": 3,
301 // Phase 5 — Nature branch (unlocks level 20)
302 "deep_root": 3, "stone_sense": 3, "bloom_tide": 3, "thorn_hide": 3,
303 "old_growth": 3, "storm_surge": 3, "predator_sense": 3, "world_tree": 3,
304 // Phase 5 — Food branch (unlocks level 30)
305 "hot_meal": 3, "sugar_rush": 3, "second_wind": 3, "caffeinated": 3,
306 "feast_mode": 3, "night_shift": 3, "brew_mastery": 3, "mythic_feast": 3,
307 // Phase 5 — People branch (unlocks level 40)
308 "mentor": 3, "crowd_favor": 3, "team_spirit": 3, "athlete_heart": 3,
309 "warrior_code": 3, "elder_wisdom": 3, "hero_legend": 3, "genie_wish": 3,
310 // Phase 5 — Things branch (unlocks level 50)
311 "tempered_grip": 3, "field_repair": 3, "master_craft": 3, "workshop_lore": 3,
312 "iron_discipline": 3, "vault_keeper": 3, "precision_forge": 3, "deep_science": 3,
313 // Phase 5 — Travel branch (unlocks level 65)
314 "long_haul": 3, "vein_compass": 3, "nomad_pace": 3, "deep_sea": 3,
315 "sky_route": 3, "monument_seal": 3, "orbital_drift": 3, "sacred_ground": 3,
316 // Phase 5 — Signs branch (unlocks level 80)
317 "amplify": 3, "resonance": 3, "sigil_power": 3, "zodiac_edge": 3,
318 "star_david": 3, "hazard_forge": 3, "cosmic_sigil": 3, "infinite_mark": 3,
319}
320
321// toolMaxLife maps toolId → maximum durability (from catalog.ts tool.life).
322// Tools with life=0 are indestructible (hands, twig).
323var toolMaxLife = map[string]uint64{
324 "hands": 0, "twig": 0, "wood": 1200, "stone": 2500, "iron": 4000,
325 "gold": 6000, "diamond": 8000, "cosmic": 10000, "plasma": 10000,
326 "neutron": 10000, "quasar": 10000, "void": 12000, "genesis": 15000, "omega": 25000,
327}
328
329func init(cur realm) {
330 admin = cur.Previous().Address()
331}
332
333// ── Admin ────────────────────────────────────────────────────────────────────
334
335func isAdmin(cur realm) bool {
336 caller := cur.Previous().Address()
337 return caller == admin || (minterAddr != address("") && caller == minterAddr)
338}
339
340// SetMinter registers the authorized minter realm address so it can call
341// ClearPendingMints. Called once after minter5 is deployed.
342func SetMinter(cur realm, newMinter string) {
343 if cur.Previous().Address() != admin {
344 panic("unauthorized: only admin can set minter")
345 }
346 minterAddr = address(newMinter)
347}
348
349func UpdateConfig(cur realm, newAdmin string) {
350 if !isAdmin(cur) {
351 panic("unauthorized")
352 }
353 admin = address(newAdmin)
354}
355
356func SetPaused(cur realm, p bool) {
357 if !isAdmin(cur) {
358 panic("unauthorized")
359 }
360 paused = p
361}
362
363func requireNotPaused() {
364 if paused {
365 panic("game is paused")
366 }
367}
368
369// ── Blacklist ─────────────────────────────────────────────────────────────────
370
371// BanAddress bans a player address. durationSecs=0 means permanent.
372func BanAddress(cur realm, targetAddr string, reason string, durationSecs int64) {
373 if cur.Previous().Address() != admin {
374 panic("unauthorized: only admin can ban")
375 }
376 if !strings.HasPrefix(targetAddr, "g1") {
377 panic("invalid address")
378 }
379 expiry := int64(0) // 0 = permanent
380 if durationSecs > 0 {
381 expiry = time.Now().Unix() + durationSecs
382 }
383 timestamp := strconv.FormatInt(time.Now().Unix(), 10)
384 blacklist.Set(targetAddr, reason+":"+timestamp)
385 banExpiries.Set(targetAddr, strconv.FormatInt(expiry, 10))
386}
387
388// UnbanAddress removes a ban.
389func UnbanAddress(cur realm, targetAddr string) {
390 if cur.Previous().Address() != admin {
391 panic("unauthorized: only admin can unban")
392 }
393 blacklist.Remove(targetAddr)
394 banExpiries.Remove(targetAddr)
395}
396
397// BlacklistStatus returns the ban status of an address as a JSON string.
398func BlacklistStatus(addr string) string {
399 if !blacklist.Has(addr) {
400 return `{"banned":false}`
401 }
402 entry := blacklist.Get(addr).(string)
403 expiryStr := banExpiries.Get(addr).(string)
404 expiry, _ := strconv.ParseInt(expiryStr, 10, 64)
405 now := time.Now().Unix()
406 if expiry > 0 && now >= expiry {
407 // Expired ban — treat as clean
408 return `{"banned":false}`
409 }
410 parts := strings.SplitN(entry, ":", 2)
411 reason := parts[0]
412 permanent := expiry == 0
413 remainSecs := int64(0)
414 if !permanent && expiry > 0 {
415 remainSecs = expiry - now
416 }
417 if permanent {
418 return `{"banned":true,"reason":"` + reason + `","permanent":true}`
419 }
420 return `{"banned":true,"reason":"` + reason + `","permanent":false,"remainingSecs":` + strconv.FormatInt(remainSecs, 10) + `}`
421}
422
423// isBlacklisted checks if an address is currently banned (unexpired).
424func isBlacklisted(addr string) bool {
425 if !blacklist.Has(addr) {
426 return false
427 }
428 expiryStr := banExpiries.Get(addr).(string)
429 expiry, _ := strconv.ParseInt(expiryStr, 10, 64)
430 if expiry == 0 {
431 return true // permanent
432 }
433 return time.Now().Unix() < expiry
434}
435
436// ── Guilds ───────────────────────────────────────────────────────────────────
437
438const maxGuildMembers = 10
439const maxGuildNameLen = 24
440
441func CreateGuild(cur realm, name string) string {
442 requireNotPaused()
443 caller := cur.Previous().Address().String()
444 if isBlacklisted(caller) {
445 panic("address banned")
446 }
447 if playerGuild.Has(caller) {
448 panic("already in a guild")
449 }
450 if len(name) < 3 {
451 panic("guild name too short (min 3 chars)")
452 }
453 if len(name) > maxGuildNameLen {
454 panic("guild name too long (max 24 chars)")
455 }
456
457 prefix := caller
458 if len(prefix) > 8 {
459 prefix = caller[:8]
460 }
461 guildId := "guild-" + prefix + "-" + strconv.FormatInt(time.Now().Unix(), 36)
462 guild := `{"guildId":"` + guildId + `","name":"` + name + `","leader":"` + caller + `","memberCount":1,"createdAt":` + strconv.FormatInt(time.Now().Unix(), 10) + `}`
463 guilds.Set(guildId, guild)
464 guildMembers.Set(guildId+":"+caller, "1")
465 playerGuild.Set(caller, guildId)
466 return guildId
467}
468
469func JoinGuild(cur realm, guildId string) {
470 requireNotPaused()
471 caller := cur.Previous().Address().String()
472 if isBlacklisted(caller) {
473 panic("address banned")
474 }
475 if playerGuild.Has(caller) {
476 panic("already in a guild — leave current guild first")
477 }
478 if !guilds.Has(guildId) {
479 panic("guild not found")
480 }
481
482 // Count current members
483 memberCount := 0
484 guildMembers.Iterate(guildId+":", guildId+":\xFF", func(key string, val any) bool {
485 memberCount++
486 return false
487 })
488 if memberCount >= maxGuildMembers {
489 panic("guild is full (max 10 members)")
490 }
491
492 // Increment memberCount in guild JSON (simple string replacement)
493 guildJson := guilds.Get(guildId).(string)
494 oldCount := `"memberCount":` + strconv.Itoa(memberCount)
495 newCount := `"memberCount":` + strconv.Itoa(memberCount+1)
496 guilds.Set(guildId, strings.Replace(guildJson, oldCount, newCount, 1))
497
498 guildMembers.Set(guildId+":"+caller, "1")
499 playerGuild.Set(caller, guildId)
500}
501
502func LeaveGuild(cur realm) {
503 requireNotPaused()
504 caller := cur.Previous().Address().String()
505 if !playerGuild.Has(caller) {
506 panic("not in a guild")
507 }
508 guildId := playerGuild.Get(caller).(string)
509
510 // Count members to determine if guild dissolves
511 memberCount := 0
512 guildMembers.Iterate(guildId+":", guildId+":\xFF", func(key string, val any) bool {
513 memberCount++
514 return false
515 })
516
517 guildMembers.Remove(guildId + ":" + caller)
518 playerGuild.Remove(caller)
519
520 if memberCount <= 1 {
521 // Last member — dissolve the guild
522 guilds.Remove(guildId)
523 } else {
524 // Update member count
525 guildJson := guilds.Get(guildId).(string)
526 oldCount := `"memberCount":` + strconv.Itoa(memberCount)
527 newCount := `"memberCount":` + strconv.Itoa(memberCount-1)
528 guilds.Set(guildId, strings.Replace(guildJson, oldCount, newCount, 1))
529 }
530}
531
532func PlayerGuild(addr string) string {
533 if !playerGuild.Has(addr) {
534 return ""
535 }
536 guildId := playerGuild.Get(addr).(string)
537 if !guilds.Has(guildId) {
538 return ""
539 }
540 return guilds.Get(guildId).(string)
541}
542
543func GuildInfo(guildId string) string {
544 if !guilds.Has(guildId) {
545 return ""
546 }
547 return guilds.Get(guildId).(string)
548}
549
550func TopGuilds(limit int) string {
551 if limit <= 0 || limit > 50 {
552 limit = 20
553 }
554 var parts []string
555 count := 0
556 guilds.Iterate("", "\xFF", func(key string, val any) bool {
557 if count >= limit {
558 return true
559 }
560 parts = append(parts, val.(string))
561 count++
562 return false
563 })
564 return "[" + strings.Join(parts, ",") + "]"
565}
566
567// ── Mining ───────────────────────────────────────────────────────────────────
568
569// ClaimMine records mined coins into pending mints for the caller.
570// coinsJson format: [["coinId","amount"],...]
571func ClaimMine(cur realm, coinsJson string) {
572 requireNotPaused()
573 caller := cur.Previous().Address().String()
574 if isBlacklisted(caller) {
575 panic("address banned")
576 }
577
578 // Velocity limit: minimum 900 seconds (15 minutes) between ClaimMine calls per address.
579 // A bot farm calling every 5s can now only call 4x/hour per wallet, and each call
580 // costs ~0.03 GNOT gas — making mass farming economically prohibitive.
581 now := time.Now().Unix()
582 if lastClaim.Has(caller) {
583 last, _ := strconv.ParseInt(lastClaim.Get(caller).(string), 10, 64)
584 if now-last < 900 {
585 remaining := 900 - (now - last)
586 panic("claiming too fast — " + strconv.FormatInt(remaining, 10) + "s remaining (15 min cooldown)")
587 }
588 }
589 lastClaim.Set(caller, strconv.FormatInt(now, 10))
590
591 coinsJson = strings.TrimSpace(coinsJson)
592 coinsJson = strings.TrimPrefix(coinsJson, "[")
593 coinsJson = strings.TrimSuffix(coinsJson, "]")
594
595 for _, entry := range splitJsonArray(coinsJson) {
596 entry = strings.TrimSpace(entry)
597 entry = strings.TrimPrefix(entry, "[")
598 entry = strings.TrimSuffix(entry, "]")
599 parts := strings.SplitN(entry, ",", 2)
600 if len(parts) != 2 {
601 continue
602 }
603 coinId := strings.Trim(strings.TrimSpace(parts[0]), `"`)
604 amtStr := strings.Trim(strings.TrimSpace(parts[1]), `"`)
605 amt, err := strconv.ParseUint(amtStr, 10, 64)
606 if err != nil || amt == 0 {
607 continue
608 }
609
610 // Per-entry cap: max 100 coins of any single type per claim.
611 // Silently caps rather than panicking — degrades gracefully for high-yield
612 // legitimate sessions while blocking bots claiming 500 Mythic in one call.
613 if amt > 100 {
614 amt = 100
615 }
616
617 // Cap at vein supply if registered
618 if veinSupply.Has(coinId) {
619 vsStr := veinSupply.Get(coinId).(string)
620 p2 := strings.SplitN(vsStr, ":", 2)
621 if len(p2) == 2 {
622 rem, _ := strconv.ParseUint(p2[0], 10, 64)
623 if rem == 0 {
624 continue
625 }
626 if amt > rem {
627 amt = rem
628 }
629 maxV, _ := strconv.ParseUint(p2[1], 10, 64)
630 veinSupply.Set(coinId, ufmt.Sprintf("%d:%d", rem-amt, maxV))
631 }
632 }
633
634 // Apply guild yield bonus: +1% per other guild member (max 9%)
635 if playerGuild.Has(caller) {
636 guildId := playerGuild.Get(caller).(string)
637 memberCount := 0
638 guildMembers.Iterate(guildId+":", guildId+":\xFF", func(k string, v any) bool {
639 memberCount++
640 return false
641 })
642 bonusPct := memberCount - 1 // 1% per other member, 0 if solo
643 if bonusPct > 9 {
644 bonusPct = 9
645 }
646 if bonusPct > 0 {
647 guildBonus := (amt * uint64(bonusPct)) / 100
648 amt = amt + guildBonus
649 }
650 }
651
652 key := caller + ":" + coinId
653
654 // Accumulate pending mints
655 ex := uint64(0)
656 if pendingMints.Has(key) {
657 ex, _ = strconv.ParseUint(pendingMints.Get(key).(string), 10, 64)
658 }
659 pendingMints.Set(key, strconv.FormatUint(ex+amt, 10))
660
661 // Track lifetime mined per coin
662 lifEx := uint64(0)
663 if lifetimeMined.Has(key) {
664 lifEx, _ = strconv.ParseUint(lifetimeMined.Get(key).(string), 10, 64)
665 }
666 lifetimeMined.Set(key, strconv.FormatUint(lifEx+amt, 10))
667
668 // Update leaderboard total (all coins summed)
669 mt := uint64(0)
670 if miners.Has(caller) {
671 mt, _ = strconv.ParseUint(miners.Get(caller).(string), 10, 64)
672 }
673 miners.Set(caller, strconv.FormatUint(mt+amt, 10))
674 }
675}
676
677// ClearPendingMints is called by the treasury server after successful GRC20 minting.
678// Removes pending balance and credits the player's spendable balances tree.
679func ClearPendingMints(cur realm, playerAddr string, coinsJson string) {
680 if !isAdmin(cur) {
681 panic("unauthorized: only admin/minter can clear pending mints")
682 }
683 coinsJson = strings.TrimSpace(coinsJson)
684 coinsJson = strings.TrimPrefix(coinsJson, "[")
685 coinsJson = strings.TrimSuffix(coinsJson, "]")
686 for _, entry := range splitJsonArray(coinsJson) {
687 coinId := strings.Trim(strings.TrimSpace(entry), `"`)
688 if coinId == "" {
689 continue
690 }
691 pendingKey := playerAddr + ":" + coinId
692
693 // Read minted amount before clearing, then credit to balances
694 if pendingMints.Has(pendingKey) {
695 amtStr := pendingMints.Get(pendingKey).(string)
696 amt, err := strconv.ParseUint(amtStr, 10, 64)
697 if err == nil && amt > 0 {
698 balKey := playerAddr + ":" + coinId
699 existing := uint64(0)
700 if balances.Has(balKey) {
701 existing, _ = strconv.ParseUint(balances.Get(balKey).(string), 10, 64)
702 }
703 balances.Set(balKey, strconv.FormatUint(existing+amt, 10))
704 }
705 }
706
707 pendingMints.Remove(pendingKey)
708 }
709}
710
711// ClaimAndMint records mined coins AND immediately credits them to balances.
712// The caller pays their own gas (~0.03 GNOT). No treasury involvement.
713// Replaces the two-step ClaimMine → mintPending flow for new players.
714// Phase 6: treasury burn-rate fix — at 100 players using ClaimAndMint, the
715// treasury pays ~0 GNOT/day (down from ~12 GNOT/day with the old two-step flow).
716func ClaimAndMint(cur realm, coinsJson string) {
717 requireNotPaused()
718 caller := cur.Previous().Address().String()
719 if isBlacklisted(caller) {
720 panic("address banned")
721 }
722
723 // Velocity limit: 15 minutes between calls (same lastClaim tree as ClaimMine).
724 // A player cannot bypass the cooldown by switching between the two functions.
725 now := time.Now().Unix()
726 if lastClaim.Has(caller) {
727 last, _ := strconv.ParseInt(lastClaim.Get(caller).(string), 10, 64)
728 if now-last < 900 {
729 remaining := 900 - (now - last)
730 panic("too fast — " + strconv.FormatInt(remaining, 10) + "s remaining (15 min cooldown)")
731 }
732 }
733 lastClaim.Set(caller, strconv.FormatInt(now, 10))
734
735 coinsJson = strings.TrimSpace(coinsJson)
736 coinsJson = strings.TrimPrefix(coinsJson, "[")
737 coinsJson = strings.TrimSuffix(coinsJson, "]")
738
739 for _, entry := range splitJsonArray(coinsJson) {
740 entry = strings.TrimSpace(entry)
741 entry = strings.TrimPrefix(entry, "[")
742 entry = strings.TrimSuffix(entry, "]")
743 parts := strings.SplitN(entry, ",", 2)
744 if len(parts) != 2 {
745 continue
746 }
747 coinId := strings.Trim(strings.TrimSpace(parts[0]), `"`)
748 amtStr := strings.Trim(strings.TrimSpace(parts[1]), `"`)
749 amt, err := strconv.ParseUint(amtStr, 10, 64)
750 if err != nil || amt == 0 {
751 continue
752 }
753
754 // Per-entry cap: max 100 per coin type (same as ClaimMine)
755 if amt > 100 {
756 amt = 100
757 }
758
759 // Cap at vein supply if registered
760 if veinSupply.Has(coinId) {
761 vsStr := veinSupply.Get(coinId).(string)
762 p2 := strings.SplitN(vsStr, ":", 2)
763 if len(p2) == 2 {
764 rem, _ := strconv.ParseUint(p2[0], 10, 64)
765 if rem == 0 {
766 continue
767 }
768 if amt > rem {
769 amt = rem
770 }
771 maxV, _ := strconv.ParseUint(p2[1], 10, 64)
772 veinSupply.Set(coinId, ufmt.Sprintf("%d:%d", rem-amt, maxV))
773 }
774 }
775
776 // Apply guild yield bonus: +1% per other guild member (max 9%)
777 if playerGuild.Has(caller) {
778 guildId := playerGuild.Get(caller).(string)
779 memberCount := 0
780 guildMembers.Iterate(guildId+":", guildId+":\xFF", func(k string, v any) bool {
781 memberCount++
782 return false
783 })
784 bonusPct := memberCount - 1 // 1% per other member, 0 if solo
785 if bonusPct > 9 {
786 bonusPct = 9
787 }
788 if bonusPct > 0 {
789 guildBonus := (amt * uint64(bonusPct)) / 100
790 amt = amt + guildBonus
791 }
792 }
793
794 key := caller + ":" + coinId
795
796 // Credit DIRECTLY to balances (no pendingMints, no treasury needed)
797 bal := uint64(0)
798 if balances.Has(key) {
799 bal, _ = strconv.ParseUint(balances.Get(key).(string), 10, 64)
800 }
801 balances.Set(key, strconv.FormatUint(bal+amt, 10))
802
803 // Track lifetime mined
804 lifEx := uint64(0)
805 if lifetimeMined.Has(key) {
806 lifEx, _ = strconv.ParseUint(lifetimeMined.Get(key).(string), 10, 64)
807 }
808 lifetimeMined.Set(key, strconv.FormatUint(lifEx+amt, 10))
809
810 // Update leaderboard
811 mt := uint64(0)
812 if miners.Has(caller) {
813 mt, _ = strconv.ParseUint(miners.Get(caller).(string), 10, 64)
814 }
815 miners.Set(caller, strconv.FormatUint(mt+amt, 10))
816 }
817}
818
819// ── Casino ───────────────────────────────────────────────────────────────────
820
821func CommitCasinoSeed(cur realm, roundId string, seedHash string) {
822 if !isAdmin(cur) {
823 panic("unauthorized")
824 }
825 casinoRounds.Set(roundId, `{"roundId":"`+roundId+`","seedHash":"`+seedHash+`","status":"committed","bets":[]}`)
826}
827
828func PlaceCasinoBet(cur realm, roundId string, game string, coinId string, betAmount string, choice string, clientSeed string) {
829 requireNotPaused()
830 caller := cur.Previous().Address().String()
831 if isBlacklisted(caller) {
832 panic("address banned")
833 }
834 amt, err := strconv.ParseUint(betAmount, 10, 64)
835 if err != nil || amt == 0 {
836 panic("invalid bet amount")
837 }
838
839 balKey := caller + ":" + coinId
840 existing := uint64(0)
841 if balances.Has(balKey) {
842 existing, _ = strconv.ParseUint(balances.Get(balKey).(string), 10, 64)
843 }
844 if existing < amt {
845 panic("insufficient balance")
846 }
847 balances.Set(balKey, strconv.FormatUint(existing-amt, 10))
848
849 if !casinoRounds.Has(roundId) {
850 panic("round not found")
851 }
852 bet := `{"addr":"` + caller + `","game":"` + game + `","coinId":"` + coinId + `","amount":` + betAmount + `,"choice":"` + choice + `","clientSeed":"` + clientSeed + `"}`
853 casinoRounds.Set(roundId+":bet:"+caller, bet)
854}
855
856func RevealCasinoSeed(cur realm, roundId string, serverSeed string) {
857 if !isAdmin(cur) {
858 panic("unauthorized")
859 }
860 casinoRounds.Set(roundId+":serverSeed", serverSeed)
861 casinoRounds.Set(roundId+":status", "revealed")
862}
863
864func RefundCasinoBet(cur realm, roundId string) {
865 requireNotPaused()
866 caller := cur.Previous().Address().String()
867 betKey := roundId + ":bet:" + caller
868 if !casinoRounds.Has(betKey) {
869 panic("no bet found for this round")
870 }
871
872 // Parse bet JSON to recover coinId and amount before removing the record.
873 // Bet JSON format: {"addr":"...","game":"...","coinId":"...","amount":N,...}
874 betJson := casinoRounds.Get(betKey).(string)
875
876 // Extract coinId (quoted string value)
877 coinId := ""
878 coinIdStart := strings.Index(betJson, `"coinId":"`) + len(`"coinId":"`)
879 if coinIdStart > len(`"coinId":"`) {
880 coinIdEnd := strings.Index(betJson[coinIdStart:], `"`) + coinIdStart
881 if coinIdEnd > coinIdStart {
882 coinId = betJson[coinIdStart:coinIdEnd]
883 }
884 }
885
886 // Extract amount (unquoted numeric value)
887 amountStr := ""
888 amountStart := strings.Index(betJson, `"amount":`) + len(`"amount":`)
889 if amountStart > len(`"amount":`) {
890 amountEnd := strings.IndexAny(betJson[amountStart:], `,}`) + amountStart
891 if amountEnd > amountStart {
892 amountStr = betJson[amountStart:amountEnd]
893 }
894 }
895
896 amount, _ := strconv.ParseUint(amountStr, 10, 64)
897 if amount > 0 && coinId != "" {
898 // Restore the deducted balance
899 balKey := caller + ":" + coinId
900 current := uint64(0)
901 if balances.Has(balKey) {
902 current, _ = strconv.ParseUint(balances.Get(balKey).(string), 10, 64)
903 }
904 balances.Set(balKey, strconv.FormatUint(current+amount, 10))
905 }
906
907 casinoRounds.Remove(betKey)
908}
909
910// ── PvP Lobbies ──────────────────────────────────────────────────────────────
911
912// CreatePvpLobby creates a new PvP lobby and escrows the creator's wager from balances.
913// Phase 6: wager > 0 deducts from balances and records the escrow in pvpWagers.
914func CreatePvpLobby(cur realm, game string, coinId string, betAmount string) string {
915 requireNotPaused()
916 caller := cur.Previous().Address().String()
917 if isBlacklisted(caller) {
918 panic("address banned")
919 }
920
921 wager, err := strconv.ParseUint(betAmount, 10, 64)
922 if err != nil {
923 panic("invalid bet amount")
924 }
925
926 // Escrow creator's wager from balances (if wager > 0)
927 if wager > 0 {
928 balKey := caller + ":" + coinId
929 bal := uint64(0)
930 if balances.Has(balKey) {
931 bal, _ = strconv.ParseUint(balances.Get(balKey).(string), 10, 64)
932 }
933 if bal < wager {
934 panic("insufficient balance for wager: need " + betAmount + " " + coinId)
935 }
936 balances.Set(balKey, strconv.FormatUint(bal-wager, 10))
937 }
938
939 prefix := caller
940 if len(prefix) > 8 {
941 prefix = caller[:8]
942 }
943 lobbyId := ufmt.Sprintf("pvp-%s-%d", prefix, time.Now().Unix())
944 lobby := `{"lobbyId":"` + lobbyId + `","creator":"` + caller + `","game":"` + game + `","coinId":"` + coinId + `","betAmount":` + betAmount + `,"status":"open","round":1,"createdAt":` + strconv.FormatInt(time.Now().Unix(), 10) + `}`
945 pvpLobbies.Set(lobbyId, lobby)
946
947 // Record wager escrow (challenger is empty until JoinPvpLobby)
948 if wager > 0 {
949 pvpWagers.Set(lobbyId, coinId+":"+betAmount+":"+caller+":")
950 }
951
952 return lobbyId
953}
954
955// JoinPvpLobby joins an existing PvP lobby and escrows the challenger's matching wager.
956// Phase 6: deducts challenger's matching wager from balances and updates pvpWagers.
957func JoinPvpLobby(cur realm, lobbyId string) {
958 requireNotPaused()
959 caller := cur.Previous().Address().String()
960 if isBlacklisted(caller) {
961 panic("address banned")
962 }
963
964 if !pvpLobbies.Has(lobbyId) {
965 panic("lobby not found")
966 }
967 if pvpLobbies.Has(lobbyId + ":challenger") {
968 panic("lobby already full")
969 }
970
971 // Parse coinId and betAmount from wager record
972 if pvpWagers.Has(lobbyId) {
973 wagerStr := pvpWagers.Get(lobbyId).(string)
974 parts := strings.SplitN(wagerStr, ":", 4)
975 if len(parts) == 4 {
976 coinId := parts[0]
977 betAmountStr := parts[1]
978 creator := parts[2]
979
980 if caller == creator {
981 panic("cannot join your own lobby")
982 }
983
984 wager, _ := strconv.ParseUint(betAmountStr, 10, 64)
985 if wager > 0 {
986 // Escrow challenger's matching wager
987 balKey := caller + ":" + coinId
988 bal := uint64(0)
989 if balances.Has(balKey) {
990 bal, _ = strconv.ParseUint(balances.Get(balKey).(string), 10, 64)
991 }
992 if bal < wager {
993 panic("insufficient balance for wager: need " + betAmountStr + " " + coinId)
994 }
995 balances.Set(balKey, strconv.FormatUint(bal-wager, 10))
996 // Update wager record with challenger address
997 pvpWagers.Set(lobbyId, coinId+":"+betAmountStr+":"+creator+":"+caller)
998 }
999 }
1000 }
1001
1002 pvpLobbies.Set(lobbyId+":challenger", caller)
1003 pvpLobbies.Set(lobbyId+":status", "joined")
1004}
1005
1006func CommitPvpMove(cur realm, lobbyId string, moveHash string) {
1007 requireNotPaused()
1008 caller := cur.Previous().Address().String()
1009 pvpLobbies.Set(lobbyId+":commit:"+caller, moveHash)
1010}
1011
1012// RevealPvpMove requires both players to have committed before either can reveal.
1013// Phase 6: when both players have revealed, determines the RPS winner and pays out
1014// 2× wager minus 2% rake to the winner. Draw = full refund to both players.
1015// pvpWagers.Remove is called after payout to prevent double-payouts.
1016func RevealPvpMove(cur realm, lobbyId string, choice string, salt string) {
1017 requireNotPaused()
1018 caller := cur.Previous().Address().String()
1019
1020 // Verify lobby exists and both players have committed
1021 if !pvpLobbies.Has(lobbyId) {
1022 panic("lobby not found")
1023 }
1024 lobbyJson := pvpLobbies.Get(lobbyId).(string)
1025 // Extract creator address from JSON
1026 creatorStart := strings.Index(lobbyJson, `"creator":"`) + len(`"creator":"`)
1027 creatorEnd := strings.Index(lobbyJson[creatorStart:], `"`) + creatorStart
1028 creator := lobbyJson[creatorStart:creatorEnd]
1029
1030 // Require challenger has joined
1031 if !pvpLobbies.Has(lobbyId + ":challenger") {
1032 panic("no opponent has joined yet")
1033 }
1034 challenger := pvpLobbies.Get(lobbyId + ":challenger").(string)
1035
1036 // Require both commits are present
1037 if !pvpLobbies.Has(lobbyId+":commit:"+creator) || !pvpLobbies.Has(lobbyId+":commit:"+challenger) {
1038 panic("both players must commit moves before revealing")
1039 }
1040
1041 pvpLobbies.Set(lobbyId+":reveal:"+caller, choice+":"+salt)
1042
1043 // Check if both players have now revealed
1044 creatorRevealed := pvpLobbies.Has(lobbyId + ":reveal:" + creator)
1045 challengerRevealed := pvpLobbies.Has(lobbyId + ":reveal:" + challenger)
1046
1047 if creatorRevealed && challengerRevealed {
1048 // Both revealed — determine winner and pay out
1049 creatorReveal := pvpLobbies.Get(lobbyId + ":reveal:" + creator).(string)
1050 challengerReveal := pvpLobbies.Get(lobbyId + ":reveal:" + challenger).(string)
1051
1052 creatorChoice := strings.SplitN(creatorReveal, ":", 2)[0]
1053 challengerChoice := strings.SplitN(challengerReveal, ":", 2)[0]
1054
1055 winner := ""
1056 // Rock-Paper-Scissors logic: rock beats scissors, scissors beats paper, paper beats rock
1057 if creatorChoice == challengerChoice {
1058 winner = "draw"
1059 } else if (creatorChoice == "rock" && challengerChoice == "scissors") ||
1060 (creatorChoice == "scissors" && challengerChoice == "paper") ||
1061 (creatorChoice == "paper" && challengerChoice == "rock") {
1062 winner = creator
1063 } else {
1064 winner = challenger
1065 }
1066
1067 pvpLobbies.Set(lobbyId+":winner", winner)
1068 pvpLobbies.Set(lobbyId+":status", "finished")
1069
1070 // Pay out wager to winner (or refund both on draw)
1071 if pvpWagers.Has(lobbyId) {
1072 wagerStr := pvpWagers.Get(lobbyId).(string)
1073 parts := strings.SplitN(wagerStr, ":", 4)
1074 if len(parts) == 4 {
1075 coinId := parts[0]
1076 wager, _ := strconv.ParseUint(parts[1], 10, 64)
1077
1078 if wager > 0 {
1079 rake := wager * 2 / 100 // 2% rake to admin
1080 payout := (wager * 2) - rake
1081
1082 if winner == "draw" {
1083 // Refund both players their wager
1084 for _, addr := range []string{creator, challenger} {
1085 balKey := addr + ":" + coinId
1086 bal := uint64(0)
1087 if balances.Has(balKey) {
1088 bal, _ = strconv.ParseUint(balances.Get(balKey).(string), 10, 64)
1089 }
1090 balances.Set(balKey, strconv.FormatUint(bal+wager, 10))
1091 }
1092 } else {
1093 // Pay winner 2× wager minus 2% rake
1094 winKey := winner + ":" + coinId
1095 winBal := uint64(0)
1096 if balances.Has(winKey) {
1097 winBal, _ = strconv.ParseUint(balances.Get(winKey).(string), 10, 64)
1098 }
1099 balances.Set(winKey, strconv.FormatUint(winBal+payout, 10))
1100
1101 // Rake goes to admin
1102 adminKey := admin.String() + ":" + coinId
1103 adminBal := uint64(0)
1104 if balances.Has(adminKey) {
1105 adminBal, _ = strconv.ParseUint(balances.Get(adminKey).(string), 10, 64)
1106 }
1107 balances.Set(adminKey, strconv.FormatUint(adminBal+rake, 10))
1108 }
1109
1110 // Remove wager record after payout to prevent double-payouts
1111 pvpWagers.Remove(lobbyId)
1112 }
1113 }
1114 }
1115 } else {
1116 pvpLobbies.Set(lobbyId+":status", "revealed")
1117 }
1118}
1119
1120// ClaimPvpTimeout allows a lobby participant to claim a timeout win.
1121// Only the creator or challenger may call this.
1122// Phase 6: refunds the escrowed wager to all parties who contributed.
1123func ClaimPvpTimeout(cur realm, lobbyId string) {
1124 requireNotPaused()
1125 caller := cur.Previous().Address().String()
1126
1127 if !pvpLobbies.Has(lobbyId) {
1128 panic("lobby not found")
1129 }
1130 lobbyJson := pvpLobbies.Get(lobbyId).(string)
1131
1132 // Extract creator address
1133 creatorStart := strings.Index(lobbyJson, `"creator":"`) + len(`"creator":"`)
1134 creatorEnd := strings.Index(lobbyJson[creatorStart:], `"`) + creatorStart
1135 creator := lobbyJson[creatorStart:creatorEnd]
1136
1137 // Get challenger (may not exist yet)
1138 challenger := ""
1139 if pvpLobbies.Has(lobbyId + ":challenger") {
1140 challenger = pvpLobbies.Get(lobbyId + ":challenger").(string)
1141 }
1142
1143 // Verify caller is a participant
1144 if caller != creator && caller != challenger {
1145 panic("not a lobby participant")
1146 }
1147
1148 // Require minimum 10 minutes since lobby creation
1149 createdAtStart := strings.Index(lobbyJson, `"createdAt":`) + len(`"createdAt":`)
1150 createdAtEnd := strings.IndexAny(lobbyJson[createdAtStart:], `,}`) + createdAtStart
1151 createdAt, _ := strconv.ParseInt(lobbyJson[createdAtStart:createdAtEnd], 10, 64)
1152 if time.Now().Unix()-createdAt < 600 {
1153 panic("timeout not yet claimable — minimum 10 minutes must elapse")
1154 }
1155
1156 pvpLobbies.Set(lobbyId+":winner", caller)
1157 pvpLobbies.Set(lobbyId+":status", "finished")
1158
1159 // Phase 6: Refund escrowed wagers.
1160 // On timeout the creator always escrowed; the challenger may or may not have joined.
1161 // Refund both if they both escrowed (challenger joined but neither revealed in time).
1162 // If only the creator escrowed (challenger never joined), refund only the creator.
1163 if pvpWagers.Has(lobbyId) {
1164 wagerStr := pvpWagers.Get(lobbyId).(string)
1165 parts := strings.SplitN(wagerStr, ":", 4)
1166 if len(parts) == 4 {
1167 coinId := parts[0]
1168 wager, _ := strconv.ParseUint(parts[1], 10, 64)
1169 creatorInWager := parts[2]
1170 challengerInWager := parts[3]
1171 // Refund whoever escrowed (creator always did; challenger may have joined)
1172 if wager > 0 {
1173 for _, addr := range []string{creatorInWager, challengerInWager} {
1174 if addr == "" {
1175 continue
1176 }
1177 balKey := addr + ":" + coinId
1178 bal := uint64(0)
1179 if balances.Has(balKey) {
1180 bal, _ = strconv.ParseUint(balances.Get(balKey).(string), 10, 64)
1181 }
1182 balances.Set(balKey, strconv.FormatUint(bal+wager, 10))
1183 }
1184 pvpWagers.Remove(lobbyId)
1185 }
1186 }
1187 }
1188}
1189
1190func ExpireLobbies(cur realm) {
1191 if !isAdmin(cur) {
1192 panic("unauthorized")
1193 }
1194}
1195
1196// ── Tools ────────────────────────────────────────────────────────────────────
1197
1198func BuyTool(cur realm, toolId string) {
1199 requireNotPaused()
1200 caller := cur.Previous().Address().String()
1201 if isBlacklisted(caller) {
1202 panic("address banned")
1203 }
1204
1205 // Look up cost
1206 costs, hasCost := toolCosts[toolId]
1207 if !hasCost && toolId != "hands" {
1208 panic("unknown tool: " + toolId)
1209 }
1210
1211 // Deduct balance (hands is free, others require coins)
1212 if hasCost && len(costs) > 0 {
1213 deductBalances(caller, costs)
1214 }
1215
1216 // Grant tool
1217 existing := ""
1218 if ownedTools.Has(caller) {
1219 existing = ownedTools.Get(caller).(string)
1220 }
1221 if existing == "" {
1222 ownedTools.Set(caller, toolId)
1223 } else {
1224 ownedTools.Set(caller, existing+","+toolId)
1225 }
1226}
1227
1228func RepairTool(cur realm, toolId string) {
1229 requireNotPaused()
1230 caller := cur.Previous().Address().String()
1231 if isBlacklisted(caller) {
1232 panic("address banned")
1233 }
1234
1235 maxLife, hasLife := toolMaxLife[toolId]
1236 if !hasLife || maxLife == 0 {
1237 panic("tool cannot be repaired: " + toolId)
1238 }
1239
1240 // Cost = 30% of craft cost (rounded up per coin)
1241 baseCosts, hasCost := toolCosts[toolId]
1242 if !hasCost {
1243 panic("unknown tool: " + toolId)
1244 }
1245 var repairCosts [][2]string
1246 for _, pair := range baseCosts {
1247 coinId, amtStr := pair[0], pair[1]
1248 baseAmt, _ := strconv.ParseUint(amtStr, 10, 64)
1249 // 30% rounded up: (base * 30 + 99) / 100
1250 repairAmt := (baseAmt*30 + 99) / 100
1251 if repairAmt > 0 {
1252 repairCosts = append(repairCosts, [2]string{coinId, strconv.FormatUint(repairAmt, 10)})
1253 }
1254 }
1255
1256 deductBalances(caller, repairCosts)
1257
1258 // Restore full durability
1259 toolLife.Set(caller+":"+toolId, strconv.FormatUint(maxLife, 10))
1260}
1261
1262// ── Progression ──────────────────────────────────────────────────────────────
1263
1264func TrainSkill(cur realm, skillId string) {
1265 requireNotPaused()
1266 caller := cur.Previous().Address().String()
1267 if isBlacklisted(caller) {
1268 panic("address banned")
1269 }
1270 key := caller + ":" + skillId
1271
1272 // Look up current rank
1273 rank := uint64(0)
1274 if skillRanks.Has(key) {
1275 rank, _ = strconv.ParseUint(skillRanks.Get(key).(string), 10, 64)
1276 }
1277
1278 // Check rank cap
1279 maxRank, hasMax := skillMaxRanks[skillId]
1280 if !hasMax {
1281 panic("unknown skill: " + skillId)
1282 }
1283 if rank >= maxRank {
1284 panic("skill already at maximum rank")
1285 }
1286
1287 // Compute cost: base × 2^rank
1288 baseCosts, hasCost := skillBaseCosts[skillId]
1289 if !hasCost {
1290 panic("unknown skill: " + skillId)
1291 }
1292 var scaledCosts [][2]string
1293 multiplier := uint64(1)
1294 for i := uint64(0); i < rank; i++ {
1295 multiplier *= 2
1296 }
1297 for _, pair := range baseCosts {
1298 coinId, amtStr := pair[0], pair[1]
1299 baseAmt, _ := strconv.ParseUint(amtStr, 10, 64)
1300 scaled := baseAmt * multiplier
1301 if scaled > 0 {
1302 scaledCosts = append(scaledCosts, [2]string{coinId, strconv.FormatUint(scaled, 10)})
1303 }
1304 }
1305
1306 deductBalances(caller, scaledCosts)
1307
1308 // Increment rank
1309 skillRanks.Set(key, strconv.FormatUint(rank+1, 10))
1310}
1311
1312func ClaimAchievement(cur realm, achievementId string) {
1313 requireNotPaused()
1314 caller := cur.Previous().Address().String()
1315 key := caller + ":" + achievementId
1316 if achievements.Has(key) {
1317 panic("already claimed")
1318 }
1319
1320 // Require that the caller has actually played (mined at least 1 coin lifetime)
1321 if !miners.Has(caller) {
1322 panic("no mining activity — play the game first")
1323 }
1324 total, _ := strconv.ParseUint(miners.Get(caller).(string), 10, 64)
1325 if total == 0 {
1326 panic("no mining activity — play the game first")
1327 }
1328
1329 achievements.Set(key, "1")
1330}
1331
1332func DailyCheckin(cur realm) {
1333 requireNotPaused()
1334 caller := cur.Previous().Address().String()
1335 now := time.Now().Unix()
1336 if dailyCheckins.Has(caller) {
1337 last, _ := strconv.ParseInt(dailyCheckins.Get(caller).(string), 10, 64)
1338 if now-last < 72000 {
1339 panic("too soon for daily check-in")
1340 }
1341 }
1342 dailyCheckins.Set(caller, strconv.FormatInt(now, 10))
1343}
1344
1345// CreateTask registers a new task on-chain.
1346func CreateTask(cur realm, taskId string, desc string) {
1347 requireNotPaused()
1348 caller := cur.Previous().Address().String()
1349 // Store task metadata keyed by taskId; only the creator can create it
1350 achievements.Set("task:"+taskId+":creator", caller)
1351 achievements.Set("task:"+taskId+":desc", desc)
1352}
1353
1354func CompleteTask(cur realm, taskId string) {
1355 requireNotPaused()
1356 caller := cur.Previous().Address().String()
1357 achievements.Set(caller+":"+taskId, "task:1")
1358}
1359
1360func RegisterVein(cur realm, coinId string, maxSupply string) {
1361 if !isAdmin(cur) {
1362 panic("unauthorized")
1363 }
1364 veinSupply.Set(coinId, maxSupply+":"+maxSupply)
1365}
1366
1367// ── Queries (pure reads — no cur realm needed) ────────────────────────────────
1368
1369func PendingMints(playerAddr string) string {
1370 var parts []string
1371 pendingMints.Iterate(playerAddr+":", playerAddr+":\xFF", func(key string, val any) bool {
1372 coinId := strings.TrimPrefix(key, playerAddr+":")
1373 parts = append(parts, `["`+coinId+`","`+val.(string)+`"]`)
1374 return false
1375 })
1376 return ufmt.Sprintf(`{"pending":[%s]}`, strings.Join(parts, ","))
1377}
1378
1379func AllBalances(playerAddr string) string {
1380 var parts []string
1381 balances.Iterate(playerAddr+":", playerAddr+":\xFF", func(key string, val any) bool {
1382 coinId := strings.TrimPrefix(key, playerAddr+":")
1383 parts = append(parts, `"`+coinId+`":`+val.(string))
1384 return false
1385 })
1386 return "{" + strings.Join(parts, ",") + "}"
1387}
1388
1389func VeinSupply(coinId string) string {
1390 if !veinSupply.Has(coinId) {
1391 return `{"remaining":"0","max":"0"}`
1392 }
1393 p := strings.SplitN(veinSupply.Get(coinId).(string), ":", 2)
1394 if len(p) != 2 {
1395 return `{"remaining":"0","max":"0"}`
1396 }
1397 return `{"remaining":"` + p[0] + `","max":"` + p[1] + `"}`
1398}
1399
1400func CasinoRound(roundId string) string {
1401 if !casinoRounds.Has(roundId) {
1402 return "null"
1403 }
1404 return casinoRounds.Get(roundId).(string)
1405}
1406
1407func CasinoHistory(_ string) string { return "[]" }
1408
1409func PvpLobby(lobbyId string) string {
1410 if !pvpLobbies.Has(lobbyId) {
1411 return "null"
1412 }
1413 return pvpLobbies.Get(lobbyId).(string)
1414}
1415
1416func OpenLobbies() string {
1417 var parts []string
1418 pvpLobbies.Iterate("pvp-", "pvp-\xFF", func(key string, val any) bool {
1419 if !strings.Contains(key, ":") {
1420 parts = append(parts, val.(string))
1421 }
1422 return false
1423 })
1424 return "[" + strings.Join(parts, ",") + "]"
1425}
1426
1427func JackpotPool(coinId string) string {
1428 return `{"pool":"0","coinId":"` + coinId + `"}`
1429}
1430
1431func PlayerProfile(playerAddr string) string {
1432 return `{"address":"` + playerAddr + `"}`
1433}
1434
1435func OwnedTools(playerAddr string) string {
1436 if !ownedTools.Has(playerAddr) {
1437 return `["hands"]`
1438 }
1439 ids := strings.Split(ownedTools.Get(playerAddr).(string), ",")
1440 var quoted []string
1441 for _, id := range ids {
1442 quoted = append(quoted, `"`+id+`"`)
1443 }
1444 return "[" + strings.Join(quoted, ",") + "]"
1445}
1446
1447func LifetimeMined(playerAddr string) string {
1448 var parts []string
1449 lifetimeMined.Iterate(playerAddr+":", playerAddr+":\xFF", func(key string, val any) bool {
1450 coinId := strings.TrimPrefix(key, playerAddr+":")
1451 parts = append(parts, `"`+coinId+`":`+val.(string))
1452 return false
1453 })
1454 return "{" + strings.Join(parts, ",") + "}"
1455}
1456
1457func AllMiners(limit int, offset int) string {
1458 var parts []string
1459 count, skipped := 0, 0
1460 miners.Iterate("", "\xFF", func(key string, val any) bool {
1461 if skipped < offset {
1462 skipped++
1463 return false
1464 }
1465 if count >= limit {
1466 return true
1467 }
1468 display := key
1469 if len(display) > 12 {
1470 display = display[:8] + "..."
1471 }
1472 parts = append(parts, `{"address":"`+key+`","display":"`+display+`","total":`+val.(string)+`}`)
1473 count++
1474 return false
1475 })
1476 return "[" + strings.Join(parts, ",") + "]"
1477}
1478
1479func GetMedals(playerAddr string) string {
1480 var claimed []string
1481 achievements.Iterate(playerAddr+":", playerAddr+":\xFF", func(key string, val any) bool {
1482 achId := strings.TrimPrefix(key, playerAddr+":")
1483 claimed = append(claimed, `"`+achId+`"`)
1484 return false
1485 })
1486 return ufmt.Sprintf(`{"medals":[%s]}`, strings.Join(claimed, ","))
1487}
1488
1489func DailyCheckinStatus(playerAddr string) string {
1490 now := time.Now().Unix()
1491 last := int64(0)
1492 if dailyCheckins.Has(playerAddr) {
1493 last, _ = strconv.ParseInt(dailyCheckins.Get(playerAddr).(string), 10, 64)
1494 }
1495 done := now-last < 72000
1496 return ufmt.Sprintf(`{"ok":true,"done":%t,"streak":1,"next":%d}`, done, last+86400)
1497}
1498
1499func ListTasks(_ string) string { return `{"ok":true,"tasks":[]}` }
1500
1501func SkillRanks(playerAddr string) string {
1502 var parts []string
1503 skillRanks.Iterate(playerAddr+":", playerAddr+":\xFF", func(key string, val any) bool {
1504 skillId := strings.TrimPrefix(key, playerAddr+":")
1505 parts = append(parts, `"`+skillId+`":`+val.(string))
1506 return false
1507 })
1508 return "{" + strings.Join(parts, ",") + "}"
1509}
1510
1511func ToolLife(playerAddr string) string {
1512 var parts []string
1513 toolLife.Iterate(playerAddr+":", playerAddr+":\xFF", func(key string, val any) bool {
1514 toolId := strings.TrimPrefix(key, playerAddr+":")
1515 parts = append(parts, `"`+toolId+`":`+val.(string))
1516 return false
1517 })
1518 return "{" + strings.Join(parts, ",") + "}"
1519}
1520
1521// ── Render ───────────────────────────────────────────────────────────────────
1522
1523func Render(_ string) string {
1524 return ufmt.Sprintf("# GnoMoji Game Realm\n\nAdmin: `%s`\n\nPath: `gno.land/r/g138gdmwj6heduu3st796lw6qqdwylgaxf7ty5gz/game9`\n\nMine emoji coins on Gno.land!\n", admin.String())
1525}
1526
1527// ── Helpers ──────────────────────────────────────────────────────────────────
1528
1529// deductBalances atomically checks all balances then deducts them.
1530// Panics if any balance is insufficient — no partial deductions.
1531// costs is a slice of [coinId, amount] pairs.
1532func deductBalances(caller string, costs [][2]string) {
1533 // First pass: verify all sufficient
1534 for _, pair := range costs {
1535 coinId, amtStr := pair[0], pair[1]
1536 required, err := strconv.ParseUint(amtStr, 10, 64)
1537 if err != nil || required == 0 {
1538 continue
1539 }
1540 balKey := caller + ":" + coinId
1541 have := uint64(0)
1542 if balances.Has(balKey) {
1543 have, _ = strconv.ParseUint(balances.Get(balKey).(string), 10, 64)
1544 }
1545 if have < required {
1546 panic("insufficient " + coinId + ": need " + amtStr + " have " + strconv.FormatUint(have, 10))
1547 }
1548 }
1549 // Second pass: deduct all
1550 for _, pair := range costs {
1551 coinId, amtStr := pair[0], pair[1]
1552 required, err := strconv.ParseUint(amtStr, 10, 64)
1553 if err != nil || required == 0 {
1554 continue
1555 }
1556 balKey := caller + ":" + coinId
1557 current, _ := strconv.ParseUint(balances.Get(balKey).(string), 10, 64)
1558 balances.Set(balKey, strconv.FormatUint(current-required, 10))
1559 }
1560}
1561
1562func splitJsonArray(s string) []string {
1563 var result []string
1564 depth, start := 0, 0
1565 for i := 0; i < len(s); i++ {
1566 switch s[i] {
1567 case '[', '{':
1568 depth++
1569 case ']', '}':
1570 depth--
1571 case ',':
1572 if depth == 0 {
1573 if e := strings.TrimSpace(s[start:i]); e != "" {
1574 result = append(result, e)
1575 }
1576 start = i + 1
1577 }
1578 }
1579 }
1580 if e := strings.TrimSpace(s[start:]); e != "" {
1581 result = append(result, e)
1582 }
1583 return result
1584}