game.gno
44.40 Kb · 1144 lines
1// GnoMoji — Main Game Realm
2// gno.land/r/g138gdmwj6heduu3st796lw6qqdwylgaxf7ty5gz/game8
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
14package game8
15
16import (
17 "strconv"
18 "strings"
19 "time"
20
21 "gno.land/p/nt/avl/v0"
22 "gno.land/p/nt/ufmt/v0"
23)
24
25// ── State ────────────────────────────────────────────────────────────────────
26
27var (
28 admin address
29 minterAddr address // authorized minter realm (minter5) — set via SetMinter
30 pendingMints avl.Tree // addr+":"+coinId → amount string
31 balances avl.Tree // addr+":"+coinId → amount string
32 veinSupply avl.Tree // coinId → "remaining:max"
33 casinoRounds avl.Tree // roundId → JSON string
34 pvpLobbies avl.Tree // lobbyId → JSON string
35 ownedTools avl.Tree // addr → comma-separated tool IDs
36 toolLife avl.Tree // addr+":"+toolId → remaining life string
37 lifetimeMined avl.Tree // addr+":"+coinId → total mined string
38 achievements avl.Tree // addr+":"+achId → "1"
39 dailyCheckins avl.Tree // addr → unix seconds string
40 skillRanks avl.Tree // addr+":"+skillId → rank string
41 miners avl.Tree // addr → total mined all-time string
42 lastClaim avl.Tree // addr → unix timestamp of last ClaimMine (velocity limit)
43 blacklist avl.Tree // addr → "reason:timestamp"
44 banExpiries avl.Tree // addr → unix expiry (0 = permanent)
45 paused bool
46)
47
48// ── Cost Tables ───────────────────────────────────────────────────────────────
49
50// toolCosts maps toolId → slice of [coinId, amount] pairs.
51// Copied from catalog.ts TOOLS array. Amounts are for qty=1.
52// Phase 5: expanded tool costs — each tool requires more ingredient types at higher tiers.
53// Matches catalog.ts TOOLS array exactly.
54var toolCosts = map[string][][2]string{
55 "twig": {
56 {"grin", "120"}, {"joy", "75"}, {"cool", "60"},
57 },
58 "wood": {
59 {"seedling", "180"}, {"leaves", "150"}, {"fallen", "120"}, {"think", "105"}, {"sleep", "90"}, {"party", "75"},
60 },
61 "stone": {
62 {"rock", "600"}, {"log", "540"}, {"evergreen", "480"}, {"brick", "420"}, {"mushroom", "360"}, {"heart", "300"},
63 {"deciduous", "240"}, {"cactus", "180"},
64 },
65 "iron": {
66 {"gear", "840"}, {"chains", "780"}, {"wrench", "720"}, {"hammer", "660"}, {"anchor", "630"}, {"pick", "570"},
67 {"nut", "400"}, {"saw", "200"}, {"hammer-wrench", "300"}, {"axe", "60"},
68 },
69 "gold": {
70 {"gold-medal", "1140"}, {"crown", "1080"}, {"trophy", "1020"}, {"money-bag", "960"}, {"bolt", "930"}, {"sword", "870"},
71 {"old-key", "80"}, {"scale", "60"}, {"bullseye", "200"}, {"mahjong", "50"}, {"slots", "20"}, {"joker", "15"},
72 },
73 "diamond": {
74 {"gem", "1560"}, {"coin", "1440"}, {"ring", "1380"}, {"disco", "1320"}, {"kite", "1260"}, {"dodo", "1140"},
75 {"scope", "25"}, {"telescope", "40"}, {"tube", "60"}, {"dish", "50"},
76 {"lock", "300"}, {"key-lock", "200"},
77 {"eight", "150"}, {"puzzle", "200"},
78 },
79 "cosmic": {
80 {"galaxy", "1950"}, {"moai", "1860"}, {"hamsa", "1800"}, {"mirror", "1740"}, {"atom", "1650"}, {"ufo", "1500"},
81 {"shooting", "15"}, {"rex", "20"}, {"mammoth", "25"}, {"new-face", "20"},
82 {"cyclone", "40"}, {"tornado", "50"},
83 {"sat", "60"}, {"castle", "80"}, {"liberty", "100"}, {"kaaba", "10"},
84 },
85 "plasma": {
86 {"galaxy", "2250"}, {"atom", "2100"}, {"moai", "2100"}, {"comet", "1950"}, {"jelly", "1800"}, {"peacock", "1800"},
87 {"om", "8"}, {"yin", "10"}, {"ophiuchus", "12"}, {"fleur", "15"}, {"trident", "15"},
88 {"radioactive", "40"}, {"biohazard", "40"},
89 {"castle-jp", "60"},
90 {"sun-face", "20"}, {"dragon-face", "25"}, {"sauropod", "30"}, {"moon-word", "80"},
91 },
92 "neutron": {
93 {"jelly", "2850"}, {"peacock", "2700"}, {"unicorn", "2550"}, {"galaxy", "2400"}, {"hamsa", "2400"}, {"dna", "2100"},
94 {"genie", "8"}, {"heart-organ", "40"}, {"mage", "50"}, {"monarch", "60"},
95 {"ninja", "40"}, {"brain", "30"}, {"lotus-sit", "40"},
96 {"lion", "60"}, {"tiger", "80"}, {"eagle", "80"}, {"wolf", "100"}, {"shark", "120"},
97 },
98 "quasar": {
99 {"unicorn", "3450"}, {"peacock", "3300"}, {"jelly", "3300"}, {"lotus", "2550"}, {"galaxy", "2850"}, {"dragon", "2550"},
100 {"fireworks", "30"}, {"medal", "25"}, {"sports-medal", "40"}, {"silver-medal", "50"}, {"bronze-medal", "60"},
101 {"tower", "80"}, {"hot-spring", "100"}, {"pirate", "60"},
102 {"fairy", "80"}, {"vampire", "80"},
103 {"heli", "40"}, {"bullet", "50"}, {"race", "80"},
104 },
105 "void": {
106 {"lotus", "3600"}, {"unicorn", "4350"}, {"dragon", "4200"}, {"peacock", "4350"}, {"jelly", "4500"}, {"galaxy", "1500"},
107 {"om", "15"}, {"yin", "15"}, {"trident", "20"}, {"fleur", "20"}, {"ophiuchus", "18"},
108 {"shooting", "25"}, {"sun-face", "30"}, {"dragon-face", "40"}, {"rex", "35"}, {"sauropod", "40"},
109 {"mammoth", "45"}, {"new-face", "35"},
110 {"kaaba", "18"},
111 {"witness", "12"}, {"genie", "12"},
112 },
113 "genesis": {
114 {"lotus", "5400"}, {"dragon", "5400"}, {"unicorn", "5250"}, {"peacock", "4950"}, {"jelly", "4800"}, {"infinity", "4200"},
115 {"genie", "12"}, {"witness", "15"},
116 {"sun-face", "40"}, {"shooting", "30"}, {"dragon-face", "50"},
117 {"om", "20"}, {"yin", "20"}, {"trident", "25"}, {"fleur", "25"},
118 {"kaaba", "20"},
119 {"rex", "40"}, {"sauropod", "45"}, {"mammoth", "50"},
120 {"new-face", "40"},
121 {"lion", "80"}, {"shark", "150"},
122 },
123 "omega": {
124 {"lotus", "6900"}, {"dragon", "6600"}, {"unicorn", "6300"}, {"peacock", "6000"}, {"jelly", "6000"}, {"infinity", "5700"},
125 {"genie", "18"},
126 {"witness", "20"},
127 {"sun-face", "50"},
128 {"shooting", "40"},
129 {"dragon-face", "60"},
130 {"om", "25"},
131 {"yin", "25"},
132 {"trident", "30"},
133 {"fleur", "30"},
134 {"ophiuchus", "25"},
135 {"kaaba", "25"},
136 {"rex", "50"},
137 {"sauropod", "55"},
138 {"mammoth", "60"},
139 {"new-face", "50"},
140 {"mage", "80"},
141 {"heart-organ", "60"},
142 {"sat", "100"},
143 },
144}
145
146// skillBaseCosts maps skillId → base cost at rank 0.
147// Actual cost at rank N = base × 2^N (doubles per rank).
148var skillBaseCosts = map[string][][2]string{
149 // STRIKE branch
150 "wrist": {{"grin", "80"}, {"joy", "20"}},
151 "palm_strike": {{"cool", "60"}, {"think", "40"}},
152 "follow": {{"fire", "40"}, {"up", "40"}},
153 "knuckle_wrap": {{"rock", "45"}, {"log", "40"}},
154 "heavy_swing": {{"brick", "35"}, {"evergreen", "30"}},
155 "stone_breaker": {{"rock", "70"}, {"hammer", "25"}, {"gear", "20"}},
156 "anvil_force": {{"chains", "35"}, {"wrench", "30"}, {"brick", "60"}},
157 "point_drive": {{"pick", "35"}, {"gear", "40"}, {"log", "90"}},
158 "kinetic_thrust": {{"rocket", "20"}, {"bolt", "12"}, {"fire", "70"}},
159 "crushing_blow": {{"crown", "10"}, {"trophy", "12"}, {"hammer", "45"}, {"bolt", "15"}},
160 "shatter_spike": {{"sword", "18"}, {"pick", "45"}, {"chains", "45"}, {"gold-medal", "12"}},
161 "ironclad_cleave": {{"anchor", "40"}, {"gear", "60"}, {"wrench", "50"}, {"money-bag", "15"}},
162 "thunder_jab": {{"bolt", "25"}, {"rocket", "30"}, {"fire", "120"}, {"wand", "15"}},
163 "titan_hammer": {{"gem", "14"}, {"coin", "18"}, {"crown", "22"}, {"trophy", "22"}, {"sword", "28"}},
164 "diamond_cleave": {{"gem", "20"}, {"ring", "12"}, {"disco", "15"}, {"pick", "70"}, {"gold-medal", "24"}},
165 "hyper_drive": {{"kite", "15"}, {"dodo", "12"}, {"bolt", "35"}, {"rocket", "45"}, {"gear", "70"}},
166 "quasar_impact": {{"galaxy", "18"}, {"moai", "18"}, {"atom", "20"}, {"ufo", "16"}, {"comet", "18"}},
167 "black_hole_crush": {{"galaxy", "30"}, {"atom", "28"}, {"moai", "24"}, {"gem", "35"}, {"ring", "28"}, {"comet", "22"}},
168 "mythic_annihilation": {{"dragon", "8"}, {"unicorn", "10"}, {"jelly", "12"}, {"peacock", "12"}, {"galaxy", "35"}, {"moai", "30"}},
169 "singularity_fist": {{"infinity", "6"}, {"lotus", "8"}, {"dragon", "10"}, {"unicorn", "12"}, {"jelly", "14"}, {"galaxy", "40"}},
170 // FORTUNE branch
171 "fortune": {{"clover", "16"}, {"heart", "40"}},
172 "four_leaf": {{"seedling", "60"}, {"leaves", "50"}},
173 "edge": {{"pizza", "20"}, {"spark", "30"}},
174 "glint": {{"spark", "50"}, {"star", "12"}},
175 "spark_chaser": {{"fire", "45"}, {"tada", "25"}},
176 "razor_focus": {{"coffee", "25"}, {"game", "25"}, {"hundred", "30"}},
177 "precision_cut": {{"eyes", "40"}, {"skull", "35"}, {"flutter", "18"}},
178 "aurora_gaze": {{"rainbow", "15"}, {"spark", "60"}, {"moon", "20"}},
179 "starlight_gleam": {{"star", "25"}, {"clover", "20"}, {"heart", "80"}},
180 "double_haul": {{"gear", "30"}, {"anchor", "20"}, {"up", "70"}},
181 "twin_strike": {{"chains", "25"}, {"wrench", "25"}, {"pick", "20"}},
182 "executioner": {{"skull", "60"}, {"sword", "18"}, {"crown", "10"}, {"orb", "8"}},
183 "celestial_sight": {{"planet", "10"}, {"moon", "30"}, {"star", "30"}, {"bead", "8"}},
184 "helix_harmony": {{"dna", "12"}, {"spark", "80"}, {"rainbow", "25"}, {"orb", "10"}},
185 "fortune_split": {{"money-bag", "15"}, {"gold-medal", "12"}, {"clover", "35"}, {"chains", "40"}},
186 "critical_surge": {{"gem", "15"}, {"disco", "14"}, {"ring", "12"}, {"orb", "20"}, {"planet", "18"}},
187 "astral_scintillation": {{"ring", "14"}, {"gem", "16"}, {"dna", "20"}, {"star", "60"}, {"moon", "50"}},
188 "plunder_frenzy": {{"coin", "25"}, {"money-bag", "30"}, {"gem", "12"}, {"crown", "25"}, {"trophy", "25"}},
189 "singularity_edge": {{"lotus", "6"}, {"dragon", "8"}, {"galaxy", "25"}, {"hamsa", "20"}, {"atom", "22"}, {"mirror", "20"}},
190 "cosmic_fortune": {{"peacock", "10"}, {"unicorn", "8"}, {"comet", "25"}, {"disco", "30"}, {"galaxy", "30"}, {"gem", "35"}},
191 // POCKET branch
192 "dustbag": {{"grin", "200"}, {"sleep", "40"}},
193 "rhythm": {{"music", "16"}, {"clap", "30"}},
194 "pocket_expansion": {{"fallen", "45"}, {"herb", "35"}},
195 "steady_tempo": {{"party", "40"}, {"wow", "30"}},
196 "cadence": {{"music", "30"}, {"coffee", "20"}, {"clap", "50"}},
197 "deep_pouch": {{"log", "70"}, {"rock", "60"}, {"mushroom", "35"}},
198 "vein_resonator": {{"flutter", "20"}, {"rainbow", "15"}, {"tada", "40"}},
199 "cart_bed": {{"log", "120"}, {"rock", "100"}, {"brick", "80"}, {"money-bag", "10"}},
200 "bounty_overflow": {{"trophy", "15"}, {"crown", "12"}, {"pick", "45"}, {"game", "35"}},
201 "fracture_pulse": {{"bolt", "25"}, {"wand", "15"}, {"hammer", "50"}, {"rocket", "25"}},
202 "metro_pulse": {{"music", "45"}, {"coffee", "35"}, {"raise", "30"}, {"bolt", "15"}},
203 "hoarders_tote": {{"coin", "22"}, {"gem", "10"}, {"money-bag", "35"}, {"rock", "150"}, {"log", "150"}},
204 "shatter_strike": {{"gem", "14"}, {"bolt", "35"}, {"wand", "25"}, {"sword", "30"}, {"hammer", "60"}},
205 "super_conductor": {{"coin", "20"}, {"ring", "12"}, {"music", "60"}, {"bolt", "30"}, {"coffee", "50"}},
206 "dodo_instinct": {{"dodo", "14"}, {"kite", "14"}, {"crown", "22"}, {"trophy", "22"}, {"eyes", "80"}},
207 "void_rend": {{"infinity", "6"}, {"jelly", "12"}, {"dragon", "8"}, {"atom", "30"}, {"comet", "25"}, {"bolt", "60"}},
208 "celestial_duplication": {{"infinity", "5"}, {"lotus", "6"}, {"dragon", "8"}, {"peacock", "10"}, {"coin", "40"}, {"gem", "35"}},
209 "eternity_flow": {{"infinity", "5"}, {"lotus", "6"}, {"peacock", "8"}, {"hamsa", "25"}, {"mirror", "25"}, {"music", "100"}},
210 "abyssal_reserve": {{"unicorn", "6"}, {"jelly", "10"}, {"moai", "25"}, {"ufo", "25"}, {"money-bag", "50"}, {"rock", "300"}},
211 "singularity_snap": {{"infinity", "8"}, {"lotus", "8"}, {"dragon", "10"}, {"galaxy", "40"}, {"moai", "30"}, {"bolt", "80"}},
212
213 // ── Phase 5: Nature branch (level 20) ────────────────────────────────────
214 "deep_root": {{"bear", "60"}, {"fox", "40"}},
215 "stone_sense": {{"turtle", "30"}, {"coral", "15"}},
216 "bloom_tide": {{"sakura", "20"}, {"bee", "50"}, {"deciduous", "80"}},
217 "thorn_hide": {{"boar", "40"}, {"snake", "30"}, {"scorpion", "20"}},
218 "old_growth": {{"elephant", "25"}, {"gorilla", "20"}, {"whale-spout", "15"}},
219 "storm_surge": {{"storm", "25"}, {"lightning-cloud", "20"}, {"tornado", "15"}},
220 "predator_sense": {{"tiger", "20"}, {"lion", "20"}, {"wolf", "20"}, {"eagle", "15"}},
221 "world_tree": {{"rex", "12"}, {"mammoth", "10"}, {"dragon-face", "8"}, {"shooting", "8"}},
222
223 // ── Phase 5: Food branch (level 30) ──────────────────────────────────────
224 "hot_meal": {{"ramen", "50"}, {"stew", "40"}},
225 "sugar_rush": {{"candy", "60"}, {"cupcake", "40"}, {"donut", "30"}},
226 "second_wind": {{"burger", "50"}, {"bacon", "40"}, {"cheese", "30"}},
227 "caffeinated": {{"boba", "40"}, {"tea", "30"}, {"sake", "25"}},
228 "feast_mode": {{"sushi", "30"}, {"curry", "40"}, {"pasta", "40"}, {"paella", "25"}},
229 "night_shift": {{"honey", "30"}, {"pie", "25"}, {"cake", "20"}},
230 "brew_mastery": {{"beer", "40"}, {"wine", "30"}, {"cocktail", "20"}, {"champagne", "15"}},
231 "mythic_feast": {{"tropical-drink", "20"}, {"cheers", "15"}, {"toast", "12"}, {"mate", "12"}},
232
233 // ── Phase 5: People branch (level 40) ────────────────────────────────────
234 "mentor": {{"baby", "60"}, {"child", "50"}},
235 "crowd_favor": {{"clap", "80"}, {"heart-hands", "60"}},
236 "team_spirit": {{"shake", "40"}, {"pray", "30"}, {"hug-pair", "30"}},
237 "athlete_heart": {{"surf", "30"}, {"swim", "30"}, {"run", "40"}, {"lift", "25"}},
238 "warrior_code": {{"ninja", "20"}, {"fencer", "15"}, {"juggle", "15"}},
239 "elder_wisdom": {{"monarch", "15"}, {"mage", "15"}, {"fairy", "12"}},
240 "hero_legend": {{"hero", "12"}, {"vampire", "10"}, {"merfolk", "10"}},
241 "genie_wish": {{"genie", "8"}, {"heart-organ", "12"}},
242
243 // ── Phase 5: Things branch (level 50) ────────────────────────────────────
244 "tempered_grip": {{"gloves", "80"}, {"hammer", "60"}},
245 "field_repair": {{"hammer-wrench", "40"}, {"saw", "30"}, {"nut", "50"}},
246 "master_craft": {{"wrench", "60"}, {"driver", "50"}, {"clamp", "30"}},
247 "workshop_lore": {{"toolbox", "30"}, {"abacus", "25"}, {"scroll", "20"}},
248 "iron_discipline": {{"axe", "30"}, {"dagger", "20"}, {"bow", "15"}},
249 "vault_keeper": {{"lock", "40"}, {"key-lock", "30"}, {"old-key", "15"}},
250 "precision_forge": {{"scope", "20"}, {"telescope", "15"}, {"tube", "18"}, {"dish", "15"}},
251 "deep_science": {{"alembic", "12"}, {"shield", "15"}, {"hammer-pick", "15"}, {"scale", "12"}},
252
253 // ── Phase 5: Travel branch (level 65) ────────────────────────────────────
254 "long_haul": {{"car", "80"}, {"bus", "60"}},
255 "vein_compass": {{"plane", "50"}, {"sail", "30"}},
256 "nomad_pace": {{"moto", "40"}, {"cycle", "50"}, {"scooter", "40"}},
257 "deep_sea": {{"ship", "30"}, {"whale-spout", "15"}, {"dolphin", "25"}},
258 "sky_route": {{"rocket", "30"}, {"heli", "20"}, {"depart", "20"}},
259 "monument_seal": {{"tower", "20"}, {"castle", "15"}, {"liberty", "12"}},
260 "orbital_drift": {{"sat", "15"}, {"castle-jp", "12"}},
261 "sacred_ground": {{"kaaba", "8"}, {"shrine", "10"}, {"temple", "10"}, {"mosque", "10"}},
262
263 // ── Phase 5: Signs branch (level 80) ─────────────────────────────────────
264 "amplify": {{"sparkle", "80"}, {"eight-star", "60"}},
265 "resonance": {{"recycle", "60"}, {"medical", "50"}},
266 "sigil_power": {{"worship", "40"}, {"peace-sign", "30"}},
267 "zodiac_edge": {{"aries", "20"}, {"taurus", "20"}, {"gemini", "20"}, {"cancer", "15"}},
268 "star_david": {{"star-david", "15"}, {"dharma", "15"}, {"cross", "15"}, {"menorah", "12"}},
269 "hazard_forge": {{"radioactive", "12"}, {"biohazard", "12"}},
270 "cosmic_sigil": {{"trident", "10"}, {"fleur", "10"}, {"ophiuchus", "8"}},
271 "infinite_mark": {{"om", "8"}, {"yin", "8"}, {"witness", "6"}},
272}
273
274// skillMaxRanks maps skillId → maximum rank allowed.
275// Most skills cap at 3 or 4 — see catalog.ts SKILLS[*].max.
276var skillMaxRanks = map[string]uint64{
277 // Strike branch
278 "wrist": 5, "palm_strike": 4, "follow": 3, "knuckle_wrap": 4, "heavy_swing": 4,
279 "stone_breaker": 4, "anvil_force": 4, "point_drive": 4, "kinetic_thrust": 4,
280 "crushing_blow": 4, "shatter_spike": 4, "ironclad_cleave": 3, "thunder_jab": 3,
281 "titan_hammer": 3, "diamond_cleave": 3, "hyper_drive": 3, "quasar_impact": 3,
282 "black_hole_crush": 3, "mythic_annihilation": 3, "singularity_fist": 3,
283 // Fortune branch
284 "fortune": 4, "four_leaf": 3, "edge": 3, "glint": 3, "spark_chaser": 3,
285 "razor_focus": 3, "precision_cut": 3, "aurora_gaze": 3, "starlight_gleam": 3,
286 "double_haul": 3, "twin_strike": 3, "executioner": 3, "celestial_sight": 3,
287 "helix_harmony": 3, "fortune_split": 3, "critical_surge": 3,
288 "astral_scintillation": 3, "plunder_frenzy": 3, "singularity_edge": 3, "cosmic_fortune": 3,
289 // Pocket branch
290 "dustbag": 3, "rhythm": 3, "pocket_expansion": 3, "steady_tempo": 3, "cadence": 3,
291 "deep_pouch": 3, "vein_resonator": 3, "cart_bed": 3, "bounty_overflow": 3,
292 "fracture_pulse": 3, "metro_pulse": 3, "hoarders_tote": 3, "shatter_strike": 3,
293 "super_conductor": 3, "dodo_instinct": 3, "void_rend": 3, "celestial_duplication": 3,
294 "eternity_flow": 3, "abyssal_reserve": 3, "singularity_snap": 3,
295 // Phase 5 — Nature branch (unlocks level 20)
296 "deep_root": 3, "stone_sense": 3, "bloom_tide": 3, "thorn_hide": 3,
297 "old_growth": 3, "storm_surge": 3, "predator_sense": 3, "world_tree": 3,
298 // Phase 5 — Food branch (unlocks level 30)
299 "hot_meal": 3, "sugar_rush": 3, "second_wind": 3, "caffeinated": 3,
300 "feast_mode": 3, "night_shift": 3, "brew_mastery": 3, "mythic_feast": 3,
301 // Phase 5 — People branch (unlocks level 40)
302 "mentor": 3, "crowd_favor": 3, "team_spirit": 3, "athlete_heart": 3,
303 "warrior_code": 3, "elder_wisdom": 3, "hero_legend": 3, "genie_wish": 3,
304 // Phase 5 — Things branch (unlocks level 50)
305 "tempered_grip": 3, "field_repair": 3, "master_craft": 3, "workshop_lore": 3,
306 "iron_discipline": 3, "vault_keeper": 3, "precision_forge": 3, "deep_science": 3,
307 // Phase 5 — Travel branch (unlocks level 65)
308 "long_haul": 3, "vein_compass": 3, "nomad_pace": 3, "deep_sea": 3,
309 "sky_route": 3, "monument_seal": 3, "orbital_drift": 3, "sacred_ground": 3,
310 // Phase 5 — Signs branch (unlocks level 80)
311 "amplify": 3, "resonance": 3, "sigil_power": 3, "zodiac_edge": 3,
312 "star_david": 3, "hazard_forge": 3, "cosmic_sigil": 3, "infinite_mark": 3,
313}
314
315// toolMaxLife maps toolId → maximum durability (from catalog.ts tool.life).
316// Tools with life=0 are indestructible (hands, twig).
317var toolMaxLife = map[string]uint64{
318 "hands": 0, "twig": 0, "wood": 1200, "stone": 2500, "iron": 4000,
319 "gold": 6000, "diamond": 8000, "cosmic": 10000, "plasma": 10000,
320 "neutron": 10000, "quasar": 10000, "void": 12000, "genesis": 15000, "omega": 25000,
321}
322
323func init(cur realm) {
324 admin = cur.Previous().Address()
325}
326
327// ── Admin ────────────────────────────────────────────────────────────────────
328
329func isAdmin(cur realm) bool {
330 caller := cur.Previous().Address()
331 return caller == admin || (minterAddr != address("") && caller == minterAddr)
332}
333
334// SetMinter registers the authorized minter realm address so it can call
335// ClearPendingMints. Called once after minter5 is deployed.
336func SetMinter(cur realm, newMinter string) {
337 if cur.Previous().Address() != admin {
338 panic("unauthorized: only admin can set minter")
339 }
340 minterAddr = address(newMinter)
341}
342
343func UpdateConfig(cur realm, newAdmin string) {
344 if !isAdmin(cur) {
345 panic("unauthorized")
346 }
347 admin = address(newAdmin)
348}
349
350func SetPaused(cur realm, p bool) {
351 if !isAdmin(cur) {
352 panic("unauthorized")
353 }
354 paused = p
355}
356
357func requireNotPaused() {
358 if paused {
359 panic("game is paused")
360 }
361}
362
363// ── Blacklist ─────────────────────────────────────────────────────────────────
364
365// BanAddress bans a player address. durationSecs=0 means permanent.
366func BanAddress(cur realm, targetAddr string, reason string, durationSecs int64) {
367 if cur.Previous().Address() != admin {
368 panic("unauthorized: only admin can ban")
369 }
370 if !strings.HasPrefix(targetAddr, "g1") {
371 panic("invalid address")
372 }
373 expiry := int64(0) // 0 = permanent
374 if durationSecs > 0 {
375 expiry = time.Now().Unix() + durationSecs
376 }
377 timestamp := strconv.FormatInt(time.Now().Unix(), 10)
378 blacklist.Set(targetAddr, reason+":"+timestamp)
379 banExpiries.Set(targetAddr, strconv.FormatInt(expiry, 10))
380}
381
382// UnbanAddress removes a ban.
383func UnbanAddress(cur realm, targetAddr string) {
384 if cur.Previous().Address() != admin {
385 panic("unauthorized: only admin can unban")
386 }
387 blacklist.Remove(targetAddr)
388 banExpiries.Remove(targetAddr)
389}
390
391// BlacklistStatus returns the ban status of an address as a JSON string.
392func BlacklistStatus(addr string) string {
393 if !blacklist.Has(addr) {
394 return `{"banned":false}`
395 }
396 entry := blacklist.Get(addr).(string)
397 expiryStr := banExpiries.Get(addr).(string)
398 expiry, _ := strconv.ParseInt(expiryStr, 10, 64)
399 now := time.Now().Unix()
400 if expiry > 0 && now >= expiry {
401 // Expired ban — treat as clean
402 return `{"banned":false}`
403 }
404 parts := strings.SplitN(entry, ":", 2)
405 reason := parts[0]
406 permanent := expiry == 0
407 remainSecs := int64(0)
408 if !permanent && expiry > 0 {
409 remainSecs = expiry - now
410 }
411 if permanent {
412 return `{"banned":true,"reason":"` + reason + `","permanent":true}`
413 }
414 return `{"banned":true,"reason":"` + reason + `","permanent":false,"remainingSecs":` + strconv.FormatInt(remainSecs, 10) + `}`
415}
416
417// isBlacklisted checks if an address is currently banned (unexpired).
418func isBlacklisted(addr string) bool {
419 if !blacklist.Has(addr) {
420 return false
421 }
422 expiryStr := banExpiries.Get(addr).(string)
423 expiry, _ := strconv.ParseInt(expiryStr, 10, 64)
424 if expiry == 0 {
425 return true // permanent
426 }
427 return time.Now().Unix() < expiry
428}
429
430// ── Mining ───────────────────────────────────────────────────────────────────
431
432// ClaimMine records mined coins into pending mints for the caller.
433// coinsJson format: [["coinId","amount"],...]
434func ClaimMine(cur realm, coinsJson string) {
435 requireNotPaused()
436 caller := cur.Previous().Address().String()
437 if isBlacklisted(caller) {
438 panic("address banned")
439 }
440
441 // Velocity limit: minimum 900 seconds (15 minutes) between ClaimMine calls per address.
442 // A bot farm calling every 5s can now only call 4x/hour per wallet, and each call
443 // costs ~0.03 GNOT gas — making mass farming economically prohibitive.
444 now := time.Now().Unix()
445 if lastClaim.Has(caller) {
446 last, _ := strconv.ParseInt(lastClaim.Get(caller).(string), 10, 64)
447 if now-last < 900 {
448 remaining := 900 - (now - last)
449 panic("claiming too fast — " + strconv.FormatInt(remaining, 10) + "s remaining (15 min cooldown)")
450 }
451 }
452 lastClaim.Set(caller, strconv.FormatInt(now, 10))
453
454 coinsJson = strings.TrimSpace(coinsJson)
455 coinsJson = strings.TrimPrefix(coinsJson, "[")
456 coinsJson = strings.TrimSuffix(coinsJson, "]")
457
458 for _, entry := range splitJsonArray(coinsJson) {
459 entry = strings.TrimSpace(entry)
460 entry = strings.TrimPrefix(entry, "[")
461 entry = strings.TrimSuffix(entry, "]")
462 parts := strings.SplitN(entry, ",", 2)
463 if len(parts) != 2 {
464 continue
465 }
466 coinId := strings.Trim(strings.TrimSpace(parts[0]), `"`)
467 amtStr := strings.Trim(strings.TrimSpace(parts[1]), `"`)
468 amt, err := strconv.ParseUint(amtStr, 10, 64)
469 if err != nil || amt == 0 {
470 continue
471 }
472
473 // Per-entry cap: max 100 coins of any single type per claim.
474 // Silently caps rather than panicking — degrades gracefully for high-yield
475 // legitimate sessions while blocking bots claiming 500 Mythic in one call.
476 if amt > 100 {
477 amt = 100
478 }
479
480 // Cap at vein supply if registered
481 if veinSupply.Has(coinId) {
482 vsStr := veinSupply.Get(coinId).(string)
483 p2 := strings.SplitN(vsStr, ":", 2)
484 if len(p2) == 2 {
485 rem, _ := strconv.ParseUint(p2[0], 10, 64)
486 if rem == 0 {
487 continue
488 }
489 if amt > rem {
490 amt = rem
491 }
492 maxV, _ := strconv.ParseUint(p2[1], 10, 64)
493 veinSupply.Set(coinId, ufmt.Sprintf("%d:%d", rem-amt, maxV))
494 }
495 }
496
497 key := caller + ":" + coinId
498
499 // Accumulate pending mints
500 ex := uint64(0)
501 if pendingMints.Has(key) {
502 ex, _ = strconv.ParseUint(pendingMints.Get(key).(string), 10, 64)
503 }
504 pendingMints.Set(key, strconv.FormatUint(ex+amt, 10))
505
506 // Track lifetime mined per coin
507 lifEx := uint64(0)
508 if lifetimeMined.Has(key) {
509 lifEx, _ = strconv.ParseUint(lifetimeMined.Get(key).(string), 10, 64)
510 }
511 lifetimeMined.Set(key, strconv.FormatUint(lifEx+amt, 10))
512
513 // Update leaderboard total (all coins summed)
514 mt := uint64(0)
515 if miners.Has(caller) {
516 mt, _ = strconv.ParseUint(miners.Get(caller).(string), 10, 64)
517 }
518 miners.Set(caller, strconv.FormatUint(mt+amt, 10))
519 }
520}
521
522// ClearPendingMints is called by the treasury server after successful GRC20 minting.
523// Removes pending balance and credits the player's spendable balances tree.
524func ClearPendingMints(cur realm, playerAddr string, coinsJson string) {
525 if !isAdmin(cur) {
526 panic("unauthorized: only admin/minter can clear pending mints")
527 }
528 coinsJson = strings.TrimSpace(coinsJson)
529 coinsJson = strings.TrimPrefix(coinsJson, "[")
530 coinsJson = strings.TrimSuffix(coinsJson, "]")
531 for _, entry := range splitJsonArray(coinsJson) {
532 coinId := strings.Trim(strings.TrimSpace(entry), `"`)
533 if coinId == "" {
534 continue
535 }
536 pendingKey := playerAddr + ":" + coinId
537
538 // Read minted amount before clearing, then credit to balances
539 if pendingMints.Has(pendingKey) {
540 amtStr := pendingMints.Get(pendingKey).(string)
541 amt, err := strconv.ParseUint(amtStr, 10, 64)
542 if err == nil && amt > 0 {
543 balKey := playerAddr + ":" + coinId
544 existing := uint64(0)
545 if balances.Has(balKey) {
546 existing, _ = strconv.ParseUint(balances.Get(balKey).(string), 10, 64)
547 }
548 balances.Set(balKey, strconv.FormatUint(existing+amt, 10))
549 }
550 }
551
552 pendingMints.Remove(pendingKey)
553 }
554}
555
556// ── Casino ───────────────────────────────────────────────────────────────────
557
558func CommitCasinoSeed(cur realm, roundId string, seedHash string) {
559 if !isAdmin(cur) {
560 panic("unauthorized")
561 }
562 casinoRounds.Set(roundId, `{"roundId":"`+roundId+`","seedHash":"`+seedHash+`","status":"committed","bets":[]}`)
563}
564
565func PlaceCasinoBet(cur realm, roundId string, game string, coinId string, betAmount string, choice string, clientSeed string) {
566 requireNotPaused()
567 caller := cur.Previous().Address().String()
568 if isBlacklisted(caller) {
569 panic("address banned")
570 }
571 amt, err := strconv.ParseUint(betAmount, 10, 64)
572 if err != nil || amt == 0 {
573 panic("invalid bet amount")
574 }
575
576 balKey := caller + ":" + coinId
577 existing := uint64(0)
578 if balances.Has(balKey) {
579 existing, _ = strconv.ParseUint(balances.Get(balKey).(string), 10, 64)
580 }
581 if existing < amt {
582 panic("insufficient balance")
583 }
584 balances.Set(balKey, strconv.FormatUint(existing-amt, 10))
585
586 if !casinoRounds.Has(roundId) {
587 panic("round not found")
588 }
589 bet := `{"addr":"` + caller + `","game":"` + game + `","coinId":"` + coinId + `","amount":` + betAmount + `,"choice":"` + choice + `","clientSeed":"` + clientSeed + `"}`
590 casinoRounds.Set(roundId+":bet:"+caller, bet)
591}
592
593func RevealCasinoSeed(cur realm, roundId string, serverSeed string) {
594 if !isAdmin(cur) {
595 panic("unauthorized")
596 }
597 casinoRounds.Set(roundId+":serverSeed", serverSeed)
598 casinoRounds.Set(roundId+":status", "revealed")
599}
600
601func RefundCasinoBet(cur realm, roundId string) {
602 requireNotPaused()
603 caller := cur.Previous().Address().String()
604 betKey := roundId + ":bet:" + caller
605 if !casinoRounds.Has(betKey) {
606 panic("no bet found for this round")
607 }
608
609 // Parse bet JSON to recover coinId and amount before removing the record.
610 // Bet JSON format: {"addr":"...","game":"...","coinId":"...","amount":N,...}
611 betJson := casinoRounds.Get(betKey).(string)
612
613 // Extract coinId (quoted string value)
614 coinId := ""
615 coinIdStart := strings.Index(betJson, `"coinId":"`) + len(`"coinId":"`)
616 if coinIdStart > len(`"coinId":"`) {
617 coinIdEnd := strings.Index(betJson[coinIdStart:], `"`) + coinIdStart
618 if coinIdEnd > coinIdStart {
619 coinId = betJson[coinIdStart:coinIdEnd]
620 }
621 }
622
623 // Extract amount (unquoted numeric value)
624 amountStr := ""
625 amountStart := strings.Index(betJson, `"amount":`) + len(`"amount":`)
626 if amountStart > len(`"amount":`) {
627 amountEnd := strings.IndexAny(betJson[amountStart:], `,}`) + amountStart
628 if amountEnd > amountStart {
629 amountStr = betJson[amountStart:amountEnd]
630 }
631 }
632
633 amount, _ := strconv.ParseUint(amountStr, 10, 64)
634 if amount > 0 && coinId != "" {
635 // Restore the deducted balance
636 balKey := caller + ":" + coinId
637 current := uint64(0)
638 if balances.Has(balKey) {
639 current, _ = strconv.ParseUint(balances.Get(balKey).(string), 10, 64)
640 }
641 balances.Set(balKey, strconv.FormatUint(current+amount, 10))
642 }
643
644 casinoRounds.Remove(betKey)
645}
646
647// ── PvP Lobbies ──────────────────────────────────────────────────────────────
648
649func CreatePvpLobby(cur realm, game string, coinId string, betAmount string) string {
650 requireNotPaused()
651 caller := cur.Previous().Address().String()
652 if isBlacklisted(caller) {
653 panic("address banned")
654 }
655 prefix := caller
656 if len(prefix) > 8 {
657 prefix = caller[:8]
658 }
659 lobbyId := ufmt.Sprintf("pvp-%s-%d", prefix, time.Now().Unix())
660 lobby := `{"lobbyId":"` + lobbyId + `","creator":"` + caller + `","game":"` + game + `","coinId":"` + coinId + `","betAmount":` + betAmount + `,"status":"open","round":1,"createdAt":` + strconv.FormatInt(time.Now().Unix(), 10) + `}`
661 pvpLobbies.Set(lobbyId, lobby)
662 return lobbyId
663}
664
665func JoinPvpLobby(cur realm, lobbyId string) {
666 requireNotPaused()
667 caller := cur.Previous().Address().String()
668 if !pvpLobbies.Has(lobbyId) {
669 panic("lobby not found")
670 }
671 pvpLobbies.Set(lobbyId+":challenger", caller)
672 pvpLobbies.Set(lobbyId+":status", "joined")
673}
674
675func CommitPvpMove(cur realm, lobbyId string, moveHash string) {
676 requireNotPaused()
677 caller := cur.Previous().Address().String()
678 pvpLobbies.Set(lobbyId+":commit:"+caller, moveHash)
679}
680
681// RevealPvpMove requires both players to have committed before either can reveal.
682func RevealPvpMove(cur realm, lobbyId string, choice string, salt string) {
683 requireNotPaused()
684 caller := cur.Previous().Address().String()
685
686 // Verify lobby exists and both players have committed
687 if !pvpLobbies.Has(lobbyId) {
688 panic("lobby not found")
689 }
690 lobbyJson := pvpLobbies.Get(lobbyId).(string)
691 // Extract creator address from JSON
692 creatorStart := strings.Index(lobbyJson, `"creator":"`) + len(`"creator":"`)
693 creatorEnd := strings.Index(lobbyJson[creatorStart:], `"`) + creatorStart
694 creator := lobbyJson[creatorStart:creatorEnd]
695
696 // Require challenger has joined
697 if !pvpLobbies.Has(lobbyId + ":challenger") {
698 panic("no opponent has joined yet")
699 }
700 challenger := pvpLobbies.Get(lobbyId + ":challenger").(string)
701
702 // Require both commits are present
703 if !pvpLobbies.Has(lobbyId+":commit:"+creator) || !pvpLobbies.Has(lobbyId+":commit:"+challenger) {
704 panic("both players must commit moves before revealing")
705 }
706
707 pvpLobbies.Set(lobbyId+":reveal:"+caller, choice+":"+salt)
708 pvpLobbies.Set(lobbyId+":status", "revealed")
709}
710
711// ClaimPvpTimeout allows a lobby participant to claim a timeout win.
712// Only the creator or challenger may call this.
713func ClaimPvpTimeout(cur realm, lobbyId string) {
714 requireNotPaused()
715 caller := cur.Previous().Address().String()
716
717 if !pvpLobbies.Has(lobbyId) {
718 panic("lobby not found")
719 }
720 lobbyJson := pvpLobbies.Get(lobbyId).(string)
721
722 // Extract creator address
723 creatorStart := strings.Index(lobbyJson, `"creator":"`) + len(`"creator":"`)
724 creatorEnd := strings.Index(lobbyJson[creatorStart:], `"`) + creatorStart
725 creator := lobbyJson[creatorStart:creatorEnd]
726
727 // Get challenger (may not exist yet)
728 challenger := ""
729 if pvpLobbies.Has(lobbyId + ":challenger") {
730 challenger = pvpLobbies.Get(lobbyId + ":challenger").(string)
731 }
732
733 // Verify caller is a participant
734 if caller != creator && caller != challenger {
735 panic("not a lobby participant")
736 }
737
738 // Require minimum 10 minutes since lobby creation
739 createdAtStart := strings.Index(lobbyJson, `"createdAt":`) + len(`"createdAt":`)
740 createdAtEnd := strings.IndexAny(lobbyJson[createdAtStart:], `,}`) + createdAtStart
741 createdAt, _ := strconv.ParseInt(lobbyJson[createdAtStart:createdAtEnd], 10, 64)
742 if time.Now().Unix()-createdAt < 600 {
743 panic("timeout not yet claimable — minimum 10 minutes must elapse")
744 }
745
746 pvpLobbies.Set(lobbyId+":winner", caller)
747 pvpLobbies.Set(lobbyId+":status", "finished")
748}
749
750func ExpireLobbies(cur realm) {
751 if !isAdmin(cur) {
752 panic("unauthorized")
753 }
754}
755
756// ── Tools ────────────────────────────────────────────────────────────────────
757
758func BuyTool(cur realm, toolId string) {
759 requireNotPaused()
760 caller := cur.Previous().Address().String()
761 if isBlacklisted(caller) {
762 panic("address banned")
763 }
764
765 // Look up cost
766 costs, hasCost := toolCosts[toolId]
767 if !hasCost && toolId != "hands" {
768 panic("unknown tool: " + toolId)
769 }
770
771 // Deduct balance (hands is free, others require coins)
772 if hasCost && len(costs) > 0 {
773 deductBalances(caller, costs)
774 }
775
776 // Grant tool
777 existing := ""
778 if ownedTools.Has(caller) {
779 existing = ownedTools.Get(caller).(string)
780 }
781 if existing == "" {
782 ownedTools.Set(caller, toolId)
783 } else {
784 ownedTools.Set(caller, existing+","+toolId)
785 }
786}
787
788func RepairTool(cur realm, toolId string) {
789 requireNotPaused()
790 caller := cur.Previous().Address().String()
791 if isBlacklisted(caller) {
792 panic("address banned")
793 }
794
795 maxLife, hasLife := toolMaxLife[toolId]
796 if !hasLife || maxLife == 0 {
797 panic("tool cannot be repaired: " + toolId)
798 }
799
800 // Cost = 30% of craft cost (rounded up per coin)
801 baseCosts, hasCost := toolCosts[toolId]
802 if !hasCost {
803 panic("unknown tool: " + toolId)
804 }
805 var repairCosts [][2]string
806 for _, pair := range baseCosts {
807 coinId, amtStr := pair[0], pair[1]
808 baseAmt, _ := strconv.ParseUint(amtStr, 10, 64)
809 // 30% rounded up: (base * 30 + 99) / 100
810 repairAmt := (baseAmt*30 + 99) / 100
811 if repairAmt > 0 {
812 repairCosts = append(repairCosts, [2]string{coinId, strconv.FormatUint(repairAmt, 10)})
813 }
814 }
815
816 deductBalances(caller, repairCosts)
817
818 // Restore full durability
819 toolLife.Set(caller+":"+toolId, strconv.FormatUint(maxLife, 10))
820}
821
822// ── Progression ──────────────────────────────────────────────────────────────
823
824func TrainSkill(cur realm, skillId string) {
825 requireNotPaused()
826 caller := cur.Previous().Address().String()
827 if isBlacklisted(caller) {
828 panic("address banned")
829 }
830 key := caller + ":" + skillId
831
832 // Look up current rank
833 rank := uint64(0)
834 if skillRanks.Has(key) {
835 rank, _ = strconv.ParseUint(skillRanks.Get(key).(string), 10, 64)
836 }
837
838 // Check rank cap
839 maxRank, hasMax := skillMaxRanks[skillId]
840 if !hasMax {
841 panic("unknown skill: " + skillId)
842 }
843 if rank >= maxRank {
844 panic("skill already at maximum rank")
845 }
846
847 // Compute cost: base × 2^rank
848 baseCosts, hasCost := skillBaseCosts[skillId]
849 if !hasCost {
850 panic("unknown skill: " + skillId)
851 }
852 var scaledCosts [][2]string
853 multiplier := uint64(1)
854 for i := uint64(0); i < rank; i++ {
855 multiplier *= 2
856 }
857 for _, pair := range baseCosts {
858 coinId, amtStr := pair[0], pair[1]
859 baseAmt, _ := strconv.ParseUint(amtStr, 10, 64)
860 scaled := baseAmt * multiplier
861 if scaled > 0 {
862 scaledCosts = append(scaledCosts, [2]string{coinId, strconv.FormatUint(scaled, 10)})
863 }
864 }
865
866 deductBalances(caller, scaledCosts)
867
868 // Increment rank
869 skillRanks.Set(key, strconv.FormatUint(rank+1, 10))
870}
871
872func ClaimAchievement(cur realm, achievementId string) {
873 requireNotPaused()
874 caller := cur.Previous().Address().String()
875 key := caller + ":" + achievementId
876 if achievements.Has(key) {
877 panic("already claimed")
878 }
879
880 // Require that the caller has actually played (mined at least 1 coin lifetime)
881 if !miners.Has(caller) {
882 panic("no mining activity — play the game first")
883 }
884 total, _ := strconv.ParseUint(miners.Get(caller).(string), 10, 64)
885 if total == 0 {
886 panic("no mining activity — play the game first")
887 }
888
889 achievements.Set(key, "1")
890}
891
892func DailyCheckin(cur realm) {
893 requireNotPaused()
894 caller := cur.Previous().Address().String()
895 now := time.Now().Unix()
896 if dailyCheckins.Has(caller) {
897 last, _ := strconv.ParseInt(dailyCheckins.Get(caller).(string), 10, 64)
898 if now-last < 72000 {
899 panic("too soon for daily check-in")
900 }
901 }
902 dailyCheckins.Set(caller, strconv.FormatInt(now, 10))
903}
904
905// CreateTask registers a new task on-chain.
906func CreateTask(cur realm, taskId string, desc string) {
907 requireNotPaused()
908 caller := cur.Previous().Address().String()
909 // Store task metadata keyed by taskId; only the creator can create it
910 achievements.Set("task:"+taskId+":creator", caller)
911 achievements.Set("task:"+taskId+":desc", desc)
912}
913
914func CompleteTask(cur realm, taskId string) {
915 requireNotPaused()
916 caller := cur.Previous().Address().String()
917 achievements.Set(caller+":"+taskId, "task:1")
918}
919
920func RegisterVein(cur realm, coinId string, maxSupply string) {
921 if !isAdmin(cur) {
922 panic("unauthorized")
923 }
924 veinSupply.Set(coinId, maxSupply+":"+maxSupply)
925}
926
927// ── Queries (pure reads — no cur realm needed) ────────────────────────────────
928
929func PendingMints(playerAddr string) string {
930 var parts []string
931 pendingMints.Iterate(playerAddr+":", playerAddr+":\xFF", func(key string, val any) bool {
932 coinId := strings.TrimPrefix(key, playerAddr+":")
933 parts = append(parts, `["`+coinId+`","`+val.(string)+`"]`)
934 return false
935 })
936 return ufmt.Sprintf(`{"pending":[%s]}`, strings.Join(parts, ","))
937}
938
939func AllBalances(playerAddr string) string {
940 var parts []string
941 balances.Iterate(playerAddr+":", playerAddr+":\xFF", func(key string, val any) bool {
942 coinId := strings.TrimPrefix(key, playerAddr+":")
943 parts = append(parts, `"`+coinId+`":`+val.(string))
944 return false
945 })
946 return "{" + strings.Join(parts, ",") + "}"
947}
948
949func VeinSupply(coinId string) string {
950 if !veinSupply.Has(coinId) {
951 return `{"remaining":"0","max":"0"}`
952 }
953 p := strings.SplitN(veinSupply.Get(coinId).(string), ":", 2)
954 if len(p) != 2 {
955 return `{"remaining":"0","max":"0"}`
956 }
957 return `{"remaining":"` + p[0] + `","max":"` + p[1] + `"}`
958}
959
960func CasinoRound(roundId string) string {
961 if !casinoRounds.Has(roundId) {
962 return "null"
963 }
964 return casinoRounds.Get(roundId).(string)
965}
966
967func CasinoHistory(_ string) string { return "[]" }
968
969func PvpLobby(lobbyId string) string {
970 if !pvpLobbies.Has(lobbyId) {
971 return "null"
972 }
973 return pvpLobbies.Get(lobbyId).(string)
974}
975
976func OpenLobbies() string {
977 var parts []string
978 pvpLobbies.Iterate("pvp-", "pvp-\xFF", func(key string, val any) bool {
979 if !strings.Contains(key, ":") {
980 parts = append(parts, val.(string))
981 }
982 return false
983 })
984 return "[" + strings.Join(parts, ",") + "]"
985}
986
987func JackpotPool(coinId string) string {
988 return `{"pool":"0","coinId":"` + coinId + `"}`
989}
990
991func PlayerProfile(playerAddr string) string {
992 return `{"address":"` + playerAddr + `"}`
993}
994
995func OwnedTools(playerAddr string) string {
996 if !ownedTools.Has(playerAddr) {
997 return `["hands"]`
998 }
999 ids := strings.Split(ownedTools.Get(playerAddr).(string), ",")
1000 var quoted []string
1001 for _, id := range ids {
1002 quoted = append(quoted, `"`+id+`"`)
1003 }
1004 return "[" + strings.Join(quoted, ",") + "]"
1005}
1006
1007func LifetimeMined(playerAddr string) string {
1008 var parts []string
1009 lifetimeMined.Iterate(playerAddr+":", playerAddr+":\xFF", func(key string, val any) bool {
1010 coinId := strings.TrimPrefix(key, playerAddr+":")
1011 parts = append(parts, `"`+coinId+`":`+val.(string))
1012 return false
1013 })
1014 return "{" + strings.Join(parts, ",") + "}"
1015}
1016
1017func AllMiners(limit int, offset int) string {
1018 var parts []string
1019 count, skipped := 0, 0
1020 miners.Iterate("", "\xFF", func(key string, val any) bool {
1021 if skipped < offset {
1022 skipped++
1023 return false
1024 }
1025 if count >= limit {
1026 return true
1027 }
1028 display := key
1029 if len(display) > 12 {
1030 display = display[:8] + "..."
1031 }
1032 parts = append(parts, `{"address":"`+key+`","display":"`+display+`","total":`+val.(string)+`}`)
1033 count++
1034 return false
1035 })
1036 return "[" + strings.Join(parts, ",") + "]"
1037}
1038
1039func GetMedals(playerAddr string) string {
1040 var claimed []string
1041 achievements.Iterate(playerAddr+":", playerAddr+":\xFF", func(key string, val any) bool {
1042 achId := strings.TrimPrefix(key, playerAddr+":")
1043 claimed = append(claimed, `"`+achId+`"`)
1044 return false
1045 })
1046 return ufmt.Sprintf(`{"medals":[%s]}`, strings.Join(claimed, ","))
1047}
1048
1049func DailyCheckinStatus(playerAddr string) string {
1050 now := time.Now().Unix()
1051 last := int64(0)
1052 if dailyCheckins.Has(playerAddr) {
1053 last, _ = strconv.ParseInt(dailyCheckins.Get(playerAddr).(string), 10, 64)
1054 }
1055 done := now-last < 72000
1056 return ufmt.Sprintf(`{"ok":true,"done":%t,"streak":1,"next":%d}`, done, last+86400)
1057}
1058
1059func ListTasks(_ string) string { return `{"ok":true,"tasks":[]}` }
1060
1061func SkillRanks(playerAddr string) string {
1062 var parts []string
1063 skillRanks.Iterate(playerAddr+":", playerAddr+":\xFF", func(key string, val any) bool {
1064 skillId := strings.TrimPrefix(key, playerAddr+":")
1065 parts = append(parts, `"`+skillId+`":`+val.(string))
1066 return false
1067 })
1068 return "{" + strings.Join(parts, ",") + "}"
1069}
1070
1071func ToolLife(playerAddr string) string {
1072 var parts []string
1073 toolLife.Iterate(playerAddr+":", playerAddr+":\xFF", func(key string, val any) bool {
1074 toolId := strings.TrimPrefix(key, playerAddr+":")
1075 parts = append(parts, `"`+toolId+`":`+val.(string))
1076 return false
1077 })
1078 return "{" + strings.Join(parts, ",") + "}"
1079}
1080
1081// ── Render ───────────────────────────────────────────────────────────────────
1082
1083func Render(_ string) string {
1084 return ufmt.Sprintf("# GnoMoji Game Realm\n\nAdmin: `%s`\n\nPath: `gno.land/r/g138gdmwj6heduu3st796lw6qqdwylgaxf7ty5gz/game8`\n\nMine emoji coins on Gno.land!\n", admin.String())
1085}
1086
1087// ── Helpers ──────────────────────────────────────────────────────────────────
1088
1089// deductBalances atomically checks all balances then deducts them.
1090// Panics if any balance is insufficient — no partial deductions.
1091// costs is a slice of [coinId, amount] pairs.
1092func deductBalances(caller string, costs [][2]string) {
1093 // First pass: verify all sufficient
1094 for _, pair := range costs {
1095 coinId, amtStr := pair[0], pair[1]
1096 required, err := strconv.ParseUint(amtStr, 10, 64)
1097 if err != nil || required == 0 {
1098 continue
1099 }
1100 balKey := caller + ":" + coinId
1101 have := uint64(0)
1102 if balances.Has(balKey) {
1103 have, _ = strconv.ParseUint(balances.Get(balKey).(string), 10, 64)
1104 }
1105 if have < required {
1106 panic("insufficient " + coinId + ": need " + amtStr + " have " + strconv.FormatUint(have, 10))
1107 }
1108 }
1109 // Second pass: deduct all
1110 for _, pair := range costs {
1111 coinId, amtStr := pair[0], pair[1]
1112 required, err := strconv.ParseUint(amtStr, 10, 64)
1113 if err != nil || required == 0 {
1114 continue
1115 }
1116 balKey := caller + ":" + coinId
1117 current, _ := strconv.ParseUint(balances.Get(balKey).(string), 10, 64)
1118 balances.Set(balKey, strconv.FormatUint(current-required, 10))
1119 }
1120}
1121
1122func splitJsonArray(s string) []string {
1123 var result []string
1124 depth, start := 0, 0
1125 for i := 0; i < len(s); i++ {
1126 switch s[i] {
1127 case '[', '{':
1128 depth++
1129 case ']', '}':
1130 depth--
1131 case ',':
1132 if depth == 0 {
1133 if e := strings.TrimSpace(s[start:i]); e != "" {
1134 result = append(result, e)
1135 }
1136 start = i + 1
1137 }
1138 }
1139 }
1140 if e := strings.TrimSpace(s[start:]); e != "" {
1141 result = append(result, e)
1142 }
1143 return result
1144}