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