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