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