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