Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

game.gno

33.22 Kb · 888 lines
  1// GnoMoji — Main Game Realm
  2// gno.land/r/g138gdmwj6heduu3st796lw6qqdwylgaxf7ty5gz/game5
  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
 11package game5
 12
 13import (
 14	"strconv"
 15	"strings"
 16	"time"
 17
 18	"gno.land/p/nt/avl/v0"
 19	"gno.land/p/nt/ufmt/v0"
 20)
 21
 22// ── State ────────────────────────────────────────────────────────────────────
 23
 24var (
 25	admin          address
 26	minterAddr     address  // authorized minter realm (minter5) — set via SetMinter
 27	pendingMints   avl.Tree // addr+":"+coinId → amount string
 28	balances       avl.Tree // addr+":"+coinId → amount string
 29	veinSupply     avl.Tree // coinId → "remaining:max"
 30	casinoRounds   avl.Tree // roundId → JSON string
 31	pvpLobbies     avl.Tree // lobbyId → JSON string
 32	ownedTools     avl.Tree // addr → comma-separated tool IDs
 33	toolLife       avl.Tree // addr+":"+toolId → remaining life string
 34	lifetimeMined  avl.Tree // addr+":"+coinId → total mined string
 35	achievements   avl.Tree // addr+":"+achId → "1"
 36	dailyCheckins  avl.Tree // addr → unix seconds string
 37	skillRanks     avl.Tree // addr+":"+skillId → rank string
 38	miners         avl.Tree // addr → total mined all-time string
 39	lastClaim      avl.Tree // addr → unix timestamp of last ClaimMine (velocity limit)
 40	paused         bool
 41)
 42
 43// ── Cost Tables ───────────────────────────────────────────────────────────────
 44
 45// toolCosts maps toolId → slice of [coinId, amount] pairs.
 46// Copied from catalog.ts TOOLS array. Amounts are for qty=1.
 47var toolCosts = map[string][][2]string{
 48	"twig":    {{"grin", "120"}, {"joy", "75"}, {"cool", "60"}},
 49	"wood":    {{"seedling", "180"}, {"leaves", "150"}, {"fallen", "120"}, {"think", "105"}, {"sleep", "90"}, {"party", "75"}},
 50	"stone":   {{"rock", "600"}, {"log", "540"}, {"evergreen", "480"}, {"brick", "420"}, {"mushroom", "360"}, {"heart", "300"}},
 51	"iron":    {{"gear", "840"}, {"chains", "780"}, {"wrench", "720"}, {"hammer", "660"}, {"anchor", "630"}, {"pick", "570"}},
 52	"gold":    {{"gold-medal", "1140"}, {"crown", "1080"}, {"trophy", "1020"}, {"money-bag", "960"}, {"bolt", "930"}, {"sword", "870"}},
 53	"diamond": {{"gem", "1560"}, {"coin", "1440"}, {"ring", "1380"}, {"disco", "1320"}, {"kite", "1260"}, {"dodo", "1140"}},
 54	"cosmic":  {{"galaxy", "1950"}, {"moai", "1860"}, {"hamsa", "1800"}, {"mirror", "1740"}, {"atom", "1650"}, {"ufo", "1500"}},
 55	"plasma":  {{"galaxy", "2250"}, {"atom", "2100"}, {"moai", "2100"}, {"comet", "1950"}, {"jelly", "1800"}, {"peacock", "1800"}},
 56	"neutron": {{"jelly", "2850"}, {"peacock", "2700"}, {"unicorn", "2550"}, {"galaxy", "2400"}, {"hamsa", "2400"}, {"dna", "2100"}},
 57	"quasar":  {{"unicorn", "3450"}, {"peacock", "3300"}, {"jelly", "3300"}, {"lotus", "2550"}, {"galaxy", "2850"}, {"dragon", "2550"}},
 58	"void":    {{"lotus", "3600"}, {"unicorn", "4350"}, {"dragon", "4200"}, {"peacock", "4350"}, {"jelly", "4500"}, {"galaxy", "1500"}},
 59	"genesis": {{"lotus", "5400"}, {"dragon", "5400"}, {"unicorn", "5250"}, {"peacock", "4950"}, {"jelly", "4800"}, {"infinity", "4200"}},
 60	"omega":   {{"lotus", "6900"}, {"dragon", "6600"}, {"unicorn", "6300"}, {"peacock", "6000"}, {"jelly", "6000"}, {"infinity", "5700"}},
 61}
 62
 63// skillBaseCosts maps skillId → base cost at rank 0.
 64// Actual cost at rank N = base × 2^N (doubles per rank).
 65var skillBaseCosts = map[string][][2]string{
 66	// STRIKE branch
 67	"wrist":               {{"grin", "80"}, {"joy", "20"}},
 68	"palm_strike":         {{"cool", "60"}, {"think", "40"}},
 69	"follow":              {{"fire", "40"}, {"up", "40"}},
 70	"knuckle_wrap":        {{"rock", "45"}, {"log", "40"}},
 71	"heavy_swing":         {{"brick", "35"}, {"evergreen", "30"}},
 72	"stone_breaker":       {{"rock", "70"}, {"hammer", "25"}, {"gear", "20"}},
 73	"anvil_force":         {{"chains", "35"}, {"wrench", "30"}, {"brick", "60"}},
 74	"point_drive":         {{"pick", "35"}, {"gear", "40"}, {"log", "90"}},
 75	"kinetic_thrust":      {{"rocket", "20"}, {"bolt", "12"}, {"fire", "70"}},
 76	"crushing_blow":       {{"crown", "10"}, {"trophy", "12"}, {"hammer", "45"}, {"bolt", "15"}},
 77	"shatter_spike":       {{"sword", "18"}, {"pick", "45"}, {"chains", "45"}, {"gold-medal", "12"}},
 78	"ironclad_cleave":     {{"anchor", "40"}, {"gear", "60"}, {"wrench", "50"}, {"money-bag", "15"}},
 79	"thunder_jab":         {{"bolt", "25"}, {"rocket", "30"}, {"fire", "120"}, {"wand", "15"}},
 80	"titan_hammer":        {{"gem", "14"}, {"coin", "18"}, {"crown", "22"}, {"trophy", "22"}, {"sword", "28"}},
 81	"diamond_cleave":      {{"gem", "20"}, {"ring", "12"}, {"disco", "15"}, {"pick", "70"}, {"gold-medal", "24"}},
 82	"hyper_drive":         {{"kite", "15"}, {"dodo", "12"}, {"bolt", "35"}, {"rocket", "45"}, {"gear", "70"}},
 83	"quasar_impact":       {{"galaxy", "18"}, {"moai", "18"}, {"atom", "20"}, {"ufo", "16"}, {"comet", "18"}},
 84	"black_hole_crush":    {{"galaxy", "30"}, {"atom", "28"}, {"moai", "24"}, {"gem", "35"}, {"ring", "28"}, {"comet", "22"}},
 85	"mythic_annihilation": {{"dragon", "8"}, {"unicorn", "10"}, {"jelly", "12"}, {"peacock", "12"}, {"galaxy", "35"}, {"moai", "30"}},
 86	"singularity_fist":    {{"infinity", "6"}, {"lotus", "8"}, {"dragon", "10"}, {"unicorn", "12"}, {"jelly", "14"}, {"galaxy", "40"}},
 87	// FORTUNE branch
 88	"fortune":             {{"clover", "16"}, {"heart", "40"}},
 89	"four_leaf":           {{"seedling", "60"}, {"leaves", "50"}},
 90	"edge":                {{"pizza", "20"}, {"spark", "30"}},
 91	"glint":               {{"spark", "50"}, {"star", "12"}},
 92	"spark_chaser":        {{"fire", "45"}, {"tada", "25"}},
 93	"razor_focus":         {{"coffee", "25"}, {"game", "25"}, {"hundred", "30"}},
 94	"precision_cut":       {{"eyes", "40"}, {"skull", "35"}, {"flutter", "18"}},
 95	"aurora_gaze":         {{"rainbow", "15"}, {"spark", "60"}, {"moon", "20"}},
 96	"starlight_gleam":     {{"star", "25"}, {"clover", "20"}, {"heart", "80"}},
 97	"double_haul":         {{"gear", "30"}, {"anchor", "20"}, {"up", "70"}},
 98	"twin_strike":         {{"chains", "25"}, {"wrench", "25"}, {"pick", "20"}},
 99	"executioner":         {{"skull", "60"}, {"sword", "18"}, {"crown", "10"}, {"orb", "8"}},
100	"celestial_sight":     {{"planet", "10"}, {"moon", "30"}, {"star", "30"}, {"bead", "8"}},
101	"helix_harmony":       {{"dna", "12"}, {"spark", "80"}, {"rainbow", "25"}, {"orb", "10"}},
102	"fortune_split":       {{"money-bag", "15"}, {"gold-medal", "12"}, {"clover", "35"}, {"chains", "40"}},
103	"critical_surge":      {{"gem", "15"}, {"disco", "14"}, {"ring", "12"}, {"orb", "20"}, {"planet", "18"}},
104	"astral_scintillation": {{"ring", "14"}, {"gem", "16"}, {"dna", "20"}, {"star", "60"}, {"moon", "50"}},
105	"plunder_frenzy":      {{"coin", "25"}, {"money-bag", "30"}, {"gem", "12"}, {"crown", "25"}, {"trophy", "25"}},
106	"singularity_edge":    {{"lotus", "6"}, {"dragon", "8"}, {"galaxy", "25"}, {"hamsa", "20"}, {"atom", "22"}, {"mirror", "20"}},
107	"cosmic_fortune":      {{"peacock", "10"}, {"unicorn", "8"}, {"comet", "25"}, {"disco", "30"}, {"galaxy", "30"}, {"gem", "35"}},
108	// POCKET branch
109	"dustbag":             {{"grin", "200"}, {"sleep", "40"}},
110	"rhythm":              {{"music", "16"}, {"clap", "30"}},
111	"pocket_expansion":    {{"fallen", "45"}, {"herb", "35"}},
112	"steady_tempo":        {{"party", "40"}, {"wow", "30"}},
113	"cadence":             {{"music", "30"}, {"coffee", "20"}, {"clap", "50"}},
114	"deep_pouch":          {{"log", "70"}, {"rock", "60"}, {"mushroom", "35"}},
115	"vein_resonator":      {{"flutter", "20"}, {"rainbow", "15"}, {"tada", "40"}},
116	"cart_bed":            {{"log", "120"}, {"rock", "100"}, {"brick", "80"}, {"money-bag", "10"}},
117	"bounty_overflow":     {{"trophy", "15"}, {"crown", "12"}, {"pick", "45"}, {"game", "35"}},
118	"fracture_pulse":      {{"bolt", "25"}, {"wand", "15"}, {"hammer", "50"}, {"rocket", "25"}},
119	"metro_pulse":         {{"music", "45"}, {"coffee", "35"}, {"raise", "30"}, {"bolt", "15"}},
120	"hoarders_tote":       {{"coin", "22"}, {"gem", "10"}, {"money-bag", "35"}, {"rock", "150"}, {"log", "150"}},
121	"shatter_strike":      {{"gem", "14"}, {"bolt", "35"}, {"wand", "25"}, {"sword", "30"}, {"hammer", "60"}},
122	"super_conductor":     {{"coin", "20"}, {"ring", "12"}, {"music", "60"}, {"bolt", "30"}, {"coffee", "50"}},
123	"dodo_instinct":       {{"dodo", "14"}, {"kite", "14"}, {"crown", "22"}, {"trophy", "22"}, {"eyes", "80"}},
124	"void_rend":           {{"infinity", "6"}, {"jelly", "12"}, {"dragon", "8"}, {"atom", "30"}, {"comet", "25"}, {"bolt", "60"}},
125	"celestial_duplication": {{"infinity", "5"}, {"lotus", "6"}, {"dragon", "8"}, {"peacock", "10"}, {"coin", "40"}, {"gem", "35"}},
126	"eternity_flow":       {{"infinity", "5"}, {"lotus", "6"}, {"peacock", "8"}, {"hamsa", "25"}, {"mirror", "25"}, {"music", "100"}},
127	"abyssal_reserve":     {{"unicorn", "6"}, {"jelly", "10"}, {"moai", "25"}, {"ufo", "25"}, {"money-bag", "50"}, {"rock", "300"}},
128	"singularity_snap":    {{"infinity", "8"}, {"lotus", "8"}, {"dragon", "10"}, {"galaxy", "40"}, {"moai", "30"}, {"bolt", "80"}},
129}
130
131// skillMaxRanks maps skillId → maximum rank allowed.
132// Most skills cap at 3 or 4 — see catalog.ts SKILLS[*].max.
133var skillMaxRanks = map[string]uint64{
134	// Strike branch (max varies: 3–5)
135	"wrist": 5, "palm_strike": 4, "follow": 3, "knuckle_wrap": 4, "heavy_swing": 4,
136	"stone_breaker": 4, "anvil_force": 4, "point_drive": 4, "kinetic_thrust": 4,
137	"crushing_blow": 4, "shatter_spike": 4, "ironclad_cleave": 3, "thunder_jab": 3,
138	"titan_hammer": 3, "diamond_cleave": 3, "hyper_drive": 3, "quasar_impact": 3,
139	"black_hole_crush": 3, "mythic_annihilation": 3, "singularity_fist": 3,
140	// Fortune branch (all max 3 or 4)
141	"fortune": 4, "four_leaf": 3, "edge": 3, "glint": 3, "spark_chaser": 3,
142	"razor_focus": 3, "precision_cut": 3, "aurora_gaze": 3, "starlight_gleam": 3,
143	"double_haul": 3, "twin_strike": 3, "executioner": 3, "celestial_sight": 3,
144	"helix_harmony": 3, "fortune_split": 3, "critical_surge": 3,
145	"astral_scintillation": 3, "plunder_frenzy": 3, "singularity_edge": 3, "cosmic_fortune": 3,
146	// Pocket branch (all max 3)
147	"dustbag": 3, "rhythm": 3, "pocket_expansion": 3, "steady_tempo": 3, "cadence": 3,
148	"deep_pouch": 3, "vein_resonator": 3, "cart_bed": 3, "bounty_overflow": 3,
149	"fracture_pulse": 3, "metro_pulse": 3, "hoarders_tote": 3, "shatter_strike": 3,
150	"super_conductor": 3, "dodo_instinct": 3, "void_rend": 3, "celestial_duplication": 3,
151	"eternity_flow": 3, "abyssal_reserve": 3, "singularity_snap": 3,
152}
153
154// toolMaxLife maps toolId → maximum durability (from catalog.ts tool.life).
155// Tools with life=0 are indestructible (hands, twig).
156var toolMaxLife = map[string]uint64{
157	"hands": 0, "twig": 0, "wood": 1200, "stone": 2500, "iron": 4000,
158	"gold": 6000, "diamond": 8000, "cosmic": 10000, "plasma": 10000,
159	"neutron": 10000, "quasar": 10000, "void": 12000, "genesis": 15000, "omega": 25000,
160}
161
162func init(cur realm) {
163	admin = cur.Previous().Address()
164}
165
166// ── Admin ────────────────────────────────────────────────────────────────────
167
168func isAdmin(cur realm) bool {
169	caller := cur.Previous().Address()
170	return caller == admin || (minterAddr != address("") && caller == minterAddr)
171}
172
173// SetMinter registers the authorized minter realm address so it can call
174// ClearPendingMints. Called once after minter5 is deployed.
175func SetMinter(cur realm, newMinter string) {
176	if cur.Previous().Address() != admin {
177		panic("unauthorized: only admin can set minter")
178	}
179	minterAddr = address(newMinter)
180}
181
182func UpdateConfig(cur realm, newAdmin string) {
183	if !isAdmin(cur) {
184		panic("unauthorized")
185	}
186	admin = address(newAdmin)
187}
188
189func SetPaused(cur realm, p bool) {
190	if !isAdmin(cur) {
191		panic("unauthorized")
192	}
193	paused = p
194}
195
196func requireNotPaused() {
197	if paused {
198		panic("game is paused")
199	}
200}
201
202// ── Mining ───────────────────────────────────────────────────────────────────
203
204// ClaimMine records mined coins into pending mints for the caller.
205// coinsJson format: [["coinId","amount"],...]
206func ClaimMine(cur realm, coinsJson string) {
207	requireNotPaused()
208	caller := cur.Previous().Address().String()
209
210	// Velocity limit: minimum 5 seconds between ClaimMine calls per address
211	now := time.Now().Unix()
212	if lastClaim.Has(caller) {
213		last, _ := strconv.ParseInt(lastClaim.Get(caller).(string), 10, 64)
214		if now-last < 5 {
215			panic("claiming too fast — please wait 5 seconds between claims")
216		}
217	}
218	lastClaim.Set(caller, strconv.FormatInt(now, 10))
219
220	coinsJson = strings.TrimSpace(coinsJson)
221	coinsJson = strings.TrimPrefix(coinsJson, "[")
222	coinsJson = strings.TrimSuffix(coinsJson, "]")
223
224	for _, entry := range splitJsonArray(coinsJson) {
225		entry = strings.TrimSpace(entry)
226		entry = strings.TrimPrefix(entry, "[")
227		entry = strings.TrimSuffix(entry, "]")
228		parts := strings.SplitN(entry, ",", 2)
229		if len(parts) != 2 {
230			continue
231		}
232		coinId := strings.Trim(strings.TrimSpace(parts[0]), `"`)
233		amtStr := strings.Trim(strings.TrimSpace(parts[1]), `"`)
234		amt, err := strconv.ParseUint(amtStr, 10, 64)
235		if err != nil || amt == 0 {
236			continue
237		}
238
239		// Cap at vein supply if registered
240		if veinSupply.Has(coinId) {
241			vsStr := veinSupply.Get(coinId).(string)
242			p2 := strings.SplitN(vsStr, ":", 2)
243			if len(p2) == 2 {
244				rem, _ := strconv.ParseUint(p2[0], 10, 64)
245				if rem == 0 {
246					continue
247				}
248				if amt > rem {
249					amt = rem
250				}
251				maxV, _ := strconv.ParseUint(p2[1], 10, 64)
252				veinSupply.Set(coinId, ufmt.Sprintf("%d:%d", rem-amt, maxV))
253			}
254		}
255
256		key := caller + ":" + coinId
257
258		// Accumulate pending mints
259		ex := uint64(0)
260		if pendingMints.Has(key) {
261			ex, _ = strconv.ParseUint(pendingMints.Get(key).(string), 10, 64)
262		}
263		pendingMints.Set(key, strconv.FormatUint(ex+amt, 10))
264
265		// Track lifetime mined per coin
266		lifEx := uint64(0)
267		if lifetimeMined.Has(key) {
268			lifEx, _ = strconv.ParseUint(lifetimeMined.Get(key).(string), 10, 64)
269		}
270		lifetimeMined.Set(key, strconv.FormatUint(lifEx+amt, 10))
271
272		// Update leaderboard total (all coins summed)
273		mt := uint64(0)
274		if miners.Has(caller) {
275			mt, _ = strconv.ParseUint(miners.Get(caller).(string), 10, 64)
276		}
277		miners.Set(caller, strconv.FormatUint(mt+amt, 10))
278	}
279}
280
281// ClearPendingMints is called by the treasury server after successful GRC20 minting.
282// Removes pending balance and credits the player's spendable balances tree.
283func ClearPendingMints(cur realm, playerAddr string, coinsJson string) {
284	if !isAdmin(cur) {
285		panic("unauthorized: only admin/minter can clear pending mints")
286	}
287	coinsJson = strings.TrimSpace(coinsJson)
288	coinsJson = strings.TrimPrefix(coinsJson, "[")
289	coinsJson = strings.TrimSuffix(coinsJson, "]")
290	for _, entry := range splitJsonArray(coinsJson) {
291		coinId := strings.Trim(strings.TrimSpace(entry), `"`)
292		if coinId == "" {
293			continue
294		}
295		pendingKey := playerAddr + ":" + coinId
296
297		// Read minted amount before clearing, then credit to balances
298		if pendingMints.Has(pendingKey) {
299			amtStr := pendingMints.Get(pendingKey).(string)
300			amt, err := strconv.ParseUint(amtStr, 10, 64)
301			if err == nil && amt > 0 {
302				balKey := playerAddr + ":" + coinId
303				existing := uint64(0)
304				if balances.Has(balKey) {
305					existing, _ = strconv.ParseUint(balances.Get(balKey).(string), 10, 64)
306				}
307				balances.Set(balKey, strconv.FormatUint(existing+amt, 10))
308			}
309		}
310
311		pendingMints.Remove(pendingKey)
312	}
313}
314
315// ── Casino ───────────────────────────────────────────────────────────────────
316
317func CommitCasinoSeed(cur realm, roundId string, seedHash string) {
318	if !isAdmin(cur) {
319		panic("unauthorized")
320	}
321	casinoRounds.Set(roundId, `{"roundId":"`+roundId+`","seedHash":"`+seedHash+`","status":"committed","bets":[]}`)
322}
323
324func PlaceCasinoBet(cur realm, roundId string, game string, coinId string, betAmount string, choice string, clientSeed string) {
325	requireNotPaused()
326	caller := cur.Previous().Address().String()
327	amt, err := strconv.ParseUint(betAmount, 10, 64)
328	if err != nil || amt == 0 {
329		panic("invalid bet amount")
330	}
331
332	balKey := caller + ":" + coinId
333	existing := uint64(0)
334	if balances.Has(balKey) {
335		existing, _ = strconv.ParseUint(balances.Get(balKey).(string), 10, 64)
336	}
337	if existing < amt {
338		panic("insufficient balance")
339	}
340	balances.Set(balKey, strconv.FormatUint(existing-amt, 10))
341
342	if !casinoRounds.Has(roundId) {
343		panic("round not found")
344	}
345	bet := `{"addr":"` + caller + `","game":"` + game + `","coinId":"` + coinId + `","amount":` + betAmount + `,"choice":"` + choice + `","clientSeed":"` + clientSeed + `"}`
346	casinoRounds.Set(roundId+":bet:"+caller, bet)
347}
348
349func RevealCasinoSeed(cur realm, roundId string, serverSeed string) {
350	if !isAdmin(cur) {
351		panic("unauthorized")
352	}
353	casinoRounds.Set(roundId+":serverSeed", serverSeed)
354	casinoRounds.Set(roundId+":status", "revealed")
355}
356
357func RefundCasinoBet(cur realm, roundId string) {
358	requireNotPaused()
359	caller := cur.Previous().Address().String()
360	betKey := roundId + ":bet:" + caller
361	if !casinoRounds.Has(betKey) {
362		panic("no bet found for this round")
363	}
364
365	// Parse bet JSON to recover coinId and amount before removing the record.
366	// Bet JSON format: {"addr":"...","game":"...","coinId":"...","amount":N,...}
367	betJson := casinoRounds.Get(betKey).(string)
368
369	// Extract coinId (quoted string value)
370	coinId := ""
371	coinIdStart := strings.Index(betJson, `"coinId":"`) + len(`"coinId":"`)
372	if coinIdStart > len(`"coinId":"`) {
373		coinIdEnd := strings.Index(betJson[coinIdStart:], `"`) + coinIdStart
374		if coinIdEnd > coinIdStart {
375			coinId = betJson[coinIdStart:coinIdEnd]
376		}
377	}
378
379	// Extract amount (unquoted numeric value)
380	amountStr := ""
381	amountStart := strings.Index(betJson, `"amount":`) + len(`"amount":`)
382	if amountStart > len(`"amount":`) {
383		amountEnd := strings.IndexAny(betJson[amountStart:], `,}`) + amountStart
384		if amountEnd > amountStart {
385			amountStr = betJson[amountStart:amountEnd]
386		}
387	}
388
389	amount, _ := strconv.ParseUint(amountStr, 10, 64)
390	if amount > 0 && coinId != "" {
391		// Restore the deducted balance
392		balKey := caller + ":" + coinId
393		current := uint64(0)
394		if balances.Has(balKey) {
395			current, _ = strconv.ParseUint(balances.Get(balKey).(string), 10, 64)
396		}
397		balances.Set(balKey, strconv.FormatUint(current+amount, 10))
398	}
399
400	casinoRounds.Remove(betKey)
401}
402
403// ── PvP Lobbies ──────────────────────────────────────────────────────────────
404
405func CreatePvpLobby(cur realm, game string, coinId string, betAmount string) string {
406	requireNotPaused()
407	caller := cur.Previous().Address().String()
408	prefix := caller
409	if len(prefix) > 8 {
410		prefix = caller[:8]
411	}
412	lobbyId := ufmt.Sprintf("pvp-%s-%d", prefix, time.Now().Unix())
413	lobby := `{"lobbyId":"` + lobbyId + `","creator":"` + caller + `","game":"` + game + `","coinId":"` + coinId + `","betAmount":` + betAmount + `,"status":"open","round":1,"createdAt":` + strconv.FormatInt(time.Now().Unix(), 10) + `}`
414	pvpLobbies.Set(lobbyId, lobby)
415	return lobbyId
416}
417
418func JoinPvpLobby(cur realm, lobbyId string) {
419	requireNotPaused()
420	caller := cur.Previous().Address().String()
421	if !pvpLobbies.Has(lobbyId) {
422		panic("lobby not found")
423	}
424	pvpLobbies.Set(lobbyId+":challenger", caller)
425	pvpLobbies.Set(lobbyId+":status", "joined")
426}
427
428func CommitPvpMove(cur realm, lobbyId string, moveHash string) {
429	requireNotPaused()
430	caller := cur.Previous().Address().String()
431	pvpLobbies.Set(lobbyId+":commit:"+caller, moveHash)
432}
433
434// RevealPvpMove requires both players to have committed before either can reveal.
435func RevealPvpMove(cur realm, lobbyId string, choice string, salt string) {
436	requireNotPaused()
437	caller := cur.Previous().Address().String()
438
439	// Verify lobby exists and both players have committed
440	if !pvpLobbies.Has(lobbyId) {
441		panic("lobby not found")
442	}
443	lobbyJson := pvpLobbies.Get(lobbyId).(string)
444	// Extract creator address from JSON
445	creatorStart := strings.Index(lobbyJson, `"creator":"`) + len(`"creator":"`)
446	creatorEnd := strings.Index(lobbyJson[creatorStart:], `"`) + creatorStart
447	creator := lobbyJson[creatorStart:creatorEnd]
448
449	// Require challenger has joined
450	if !pvpLobbies.Has(lobbyId + ":challenger") {
451		panic("no opponent has joined yet")
452	}
453	challenger := pvpLobbies.Get(lobbyId + ":challenger").(string)
454
455	// Require both commits are present
456	if !pvpLobbies.Has(lobbyId+":commit:"+creator) || !pvpLobbies.Has(lobbyId+":commit:"+challenger) {
457		panic("both players must commit moves before revealing")
458	}
459
460	pvpLobbies.Set(lobbyId+":reveal:"+caller, choice+":"+salt)
461	pvpLobbies.Set(lobbyId+":status", "revealed")
462}
463
464// ClaimPvpTimeout allows a lobby participant to claim a timeout win.
465// Only the creator or challenger may call this.
466func ClaimPvpTimeout(cur realm, lobbyId string) {
467	requireNotPaused()
468	caller := cur.Previous().Address().String()
469
470	if !pvpLobbies.Has(lobbyId) {
471		panic("lobby not found")
472	}
473	lobbyJson := pvpLobbies.Get(lobbyId).(string)
474
475	// Extract creator address
476	creatorStart := strings.Index(lobbyJson, `"creator":"`) + len(`"creator":"`)
477	creatorEnd := strings.Index(lobbyJson[creatorStart:], `"`) + creatorStart
478	creator := lobbyJson[creatorStart:creatorEnd]
479
480	// Get challenger (may not exist yet)
481	challenger := ""
482	if pvpLobbies.Has(lobbyId + ":challenger") {
483		challenger = pvpLobbies.Get(lobbyId + ":challenger").(string)
484	}
485
486	// Verify caller is a participant
487	if caller != creator && caller != challenger {
488		panic("not a lobby participant")
489	}
490
491	// Require minimum 10 minutes since lobby creation
492	createdAtStart := strings.Index(lobbyJson, `"createdAt":`) + len(`"createdAt":`)
493	createdAtEnd := strings.IndexAny(lobbyJson[createdAtStart:], `,}`) + createdAtStart
494	createdAt, _ := strconv.ParseInt(lobbyJson[createdAtStart:createdAtEnd], 10, 64)
495	if time.Now().Unix()-createdAt < 600 {
496		panic("timeout not yet claimable — minimum 10 minutes must elapse")
497	}
498
499	pvpLobbies.Set(lobbyId+":winner", caller)
500	pvpLobbies.Set(lobbyId+":status", "finished")
501}
502
503func ExpireLobbies(cur realm) {
504	if !isAdmin(cur) {
505		panic("unauthorized")
506	}
507}
508
509// ── Tools ────────────────────────────────────────────────────────────────────
510
511func BuyTool(cur realm, toolId string) {
512	requireNotPaused()
513	caller := cur.Previous().Address().String()
514
515	// Look up cost
516	costs, hasCost := toolCosts[toolId]
517	if !hasCost && toolId != "hands" {
518		panic("unknown tool: " + toolId)
519	}
520
521	// Deduct balance (hands is free, others require coins)
522	if hasCost && len(costs) > 0 {
523		deductBalances(caller, costs)
524	}
525
526	// Grant tool
527	existing := ""
528	if ownedTools.Has(caller) {
529		existing = ownedTools.Get(caller).(string)
530	}
531	if existing == "" {
532		ownedTools.Set(caller, toolId)
533	} else {
534		ownedTools.Set(caller, existing+","+toolId)
535	}
536}
537
538func RepairTool(cur realm, toolId string) {
539	requireNotPaused()
540	caller := cur.Previous().Address().String()
541
542	maxLife, hasLife := toolMaxLife[toolId]
543	if !hasLife || maxLife == 0 {
544		panic("tool cannot be repaired: " + toolId)
545	}
546
547	// Cost = 30% of craft cost (rounded up per coin)
548	baseCosts, hasCost := toolCosts[toolId]
549	if !hasCost {
550		panic("unknown tool: " + toolId)
551	}
552	var repairCosts [][2]string
553	for _, pair := range baseCosts {
554		coinId, amtStr := pair[0], pair[1]
555		baseAmt, _ := strconv.ParseUint(amtStr, 10, 64)
556		// 30% rounded up: (base * 30 + 99) / 100
557		repairAmt := (baseAmt*30 + 99) / 100
558		if repairAmt > 0 {
559			repairCosts = append(repairCosts, [2]string{coinId, strconv.FormatUint(repairAmt, 10)})
560		}
561	}
562
563	deductBalances(caller, repairCosts)
564
565	// Restore full durability
566	toolLife.Set(caller+":"+toolId, strconv.FormatUint(maxLife, 10))
567}
568
569// ── Progression ──────────────────────────────────────────────────────────────
570
571func TrainSkill(cur realm, skillId string) {
572	requireNotPaused()
573	caller := cur.Previous().Address().String()
574	key := caller + ":" + skillId
575
576	// Look up current rank
577	rank := uint64(0)
578	if skillRanks.Has(key) {
579		rank, _ = strconv.ParseUint(skillRanks.Get(key).(string), 10, 64)
580	}
581
582	// Check rank cap
583	maxRank, hasMax := skillMaxRanks[skillId]
584	if !hasMax {
585		panic("unknown skill: " + skillId)
586	}
587	if rank >= maxRank {
588		panic("skill already at maximum rank")
589	}
590
591	// Compute cost: base × 2^rank
592	baseCosts, hasCost := skillBaseCosts[skillId]
593	if !hasCost {
594		panic("unknown skill: " + skillId)
595	}
596	var scaledCosts [][2]string
597	multiplier := uint64(1)
598	for i := uint64(0); i < rank; i++ {
599		multiplier *= 2
600	}
601	for _, pair := range baseCosts {
602		coinId, amtStr := pair[0], pair[1]
603		baseAmt, _ := strconv.ParseUint(amtStr, 10, 64)
604		scaled := baseAmt * multiplier
605		if scaled > 0 {
606			scaledCosts = append(scaledCosts, [2]string{coinId, strconv.FormatUint(scaled, 10)})
607		}
608	}
609
610	deductBalances(caller, scaledCosts)
611
612	// Increment rank
613	skillRanks.Set(key, strconv.FormatUint(rank+1, 10))
614}
615
616func ClaimAchievement(cur realm, achievementId string) {
617	requireNotPaused()
618	caller := cur.Previous().Address().String()
619	key := caller + ":" + achievementId
620	if achievements.Has(key) {
621		panic("already claimed")
622	}
623
624	// Require that the caller has actually played (mined at least 1 coin lifetime)
625	if !miners.Has(caller) {
626		panic("no mining activity — play the game first")
627	}
628	total, _ := strconv.ParseUint(miners.Get(caller).(string), 10, 64)
629	if total == 0 {
630		panic("no mining activity — play the game first")
631	}
632
633	achievements.Set(key, "1")
634}
635
636func DailyCheckin(cur realm) {
637	requireNotPaused()
638	caller := cur.Previous().Address().String()
639	now := time.Now().Unix()
640	if dailyCheckins.Has(caller) {
641		last, _ := strconv.ParseInt(dailyCheckins.Get(caller).(string), 10, 64)
642		if now-last < 72000 {
643			panic("too soon for daily check-in")
644		}
645	}
646	dailyCheckins.Set(caller, strconv.FormatInt(now, 10))
647}
648
649// CreateTask registers a new task on-chain.
650func CreateTask(cur realm, taskId string, desc string) {
651	requireNotPaused()
652	caller := cur.Previous().Address().String()
653	// Store task metadata keyed by taskId; only the creator can create it
654	achievements.Set("task:"+taskId+":creator", caller)
655	achievements.Set("task:"+taskId+":desc", desc)
656}
657
658func CompleteTask(cur realm, taskId string) {
659	requireNotPaused()
660	caller := cur.Previous().Address().String()
661	achievements.Set(caller+":"+taskId, "task:1")
662}
663
664func RegisterVein(cur realm, coinId string, maxSupply string) {
665	if !isAdmin(cur) {
666		panic("unauthorized")
667	}
668	veinSupply.Set(coinId, maxSupply+":"+maxSupply)
669}
670
671// ── Queries (pure reads — no cur realm needed) ────────────────────────────────
672
673func PendingMints(playerAddr string) string {
674	var parts []string
675	pendingMints.Iterate(playerAddr+":", playerAddr+":\xFF", func(key string, val any) bool {
676		coinId := strings.TrimPrefix(key, playerAddr+":")
677		parts = append(parts, `["`+coinId+`","`+val.(string)+`"]`)
678		return false
679	})
680	return ufmt.Sprintf(`{"pending":[%s]}`, strings.Join(parts, ","))
681}
682
683func AllBalances(playerAddr string) string {
684	var parts []string
685	balances.Iterate(playerAddr+":", playerAddr+":\xFF", func(key string, val any) bool {
686		coinId := strings.TrimPrefix(key, playerAddr+":")
687		parts = append(parts, `"`+coinId+`":`+val.(string))
688		return false
689	})
690	return "{" + strings.Join(parts, ",") + "}"
691}
692
693func VeinSupply(coinId string) string {
694	if !veinSupply.Has(coinId) {
695		return `{"remaining":"0","max":"0"}`
696	}
697	p := strings.SplitN(veinSupply.Get(coinId).(string), ":", 2)
698	if len(p) != 2 {
699		return `{"remaining":"0","max":"0"}`
700	}
701	return `{"remaining":"` + p[0] + `","max":"` + p[1] + `"}`
702}
703
704func CasinoRound(roundId string) string {
705	if !casinoRounds.Has(roundId) {
706		return "null"
707	}
708	return casinoRounds.Get(roundId).(string)
709}
710
711func CasinoHistory(_ string) string { return "[]" }
712
713func PvpLobby(lobbyId string) string {
714	if !pvpLobbies.Has(lobbyId) {
715		return "null"
716	}
717	return pvpLobbies.Get(lobbyId).(string)
718}
719
720func OpenLobbies() string {
721	var parts []string
722	pvpLobbies.Iterate("pvp-", "pvp-\xFF", func(key string, val any) bool {
723		if !strings.Contains(key, ":") {
724			parts = append(parts, val.(string))
725		}
726		return false
727	})
728	return "[" + strings.Join(parts, ",") + "]"
729}
730
731func JackpotPool(coinId string) string {
732	return `{"pool":"0","coinId":"` + coinId + `"}`
733}
734
735func PlayerProfile(playerAddr string) string {
736	return `{"address":"` + playerAddr + `"}`
737}
738
739func OwnedTools(playerAddr string) string {
740	if !ownedTools.Has(playerAddr) {
741		return `["hands"]`
742	}
743	ids := strings.Split(ownedTools.Get(playerAddr).(string), ",")
744	var quoted []string
745	for _, id := range ids {
746		quoted = append(quoted, `"`+id+`"`)
747	}
748	return "[" + strings.Join(quoted, ",") + "]"
749}
750
751func LifetimeMined(playerAddr string) string {
752	var parts []string
753	lifetimeMined.Iterate(playerAddr+":", playerAddr+":\xFF", func(key string, val any) bool {
754		coinId := strings.TrimPrefix(key, playerAddr+":")
755		parts = append(parts, `"`+coinId+`":`+val.(string))
756		return false
757	})
758	return "{" + strings.Join(parts, ",") + "}"
759}
760
761func AllMiners(limit int, offset int) string {
762	var parts []string
763	count, skipped := 0, 0
764	miners.Iterate("", "\xFF", func(key string, val any) bool {
765		if skipped < offset {
766			skipped++
767			return false
768		}
769		if count >= limit {
770			return true
771		}
772		display := key
773		if len(display) > 12 {
774			display = display[:8] + "..."
775		}
776		parts = append(parts, `{"address":"`+key+`","display":"`+display+`","total":`+val.(string)+`}`)
777		count++
778		return false
779	})
780	return "[" + strings.Join(parts, ",") + "]"
781}
782
783func GetMedals(playerAddr string) string {
784	var claimed []string
785	achievements.Iterate(playerAddr+":", playerAddr+":\xFF", func(key string, val any) bool {
786		achId := strings.TrimPrefix(key, playerAddr+":")
787		claimed = append(claimed, `"`+achId+`"`)
788		return false
789	})
790	return ufmt.Sprintf(`{"medals":[%s]}`, strings.Join(claimed, ","))
791}
792
793func DailyCheckinStatus(playerAddr string) string {
794	now := time.Now().Unix()
795	last := int64(0)
796	if dailyCheckins.Has(playerAddr) {
797		last, _ = strconv.ParseInt(dailyCheckins.Get(playerAddr).(string), 10, 64)
798	}
799	done := now-last < 72000
800	return ufmt.Sprintf(`{"ok":true,"done":%t,"streak":1,"next":%d}`, done, last+86400)
801}
802
803func ListTasks(_ string) string { return `{"ok":true,"tasks":[]}` }
804
805func SkillRanks(playerAddr string) string {
806	var parts []string
807	skillRanks.Iterate(playerAddr+":", playerAddr+":\xFF", func(key string, val any) bool {
808		skillId := strings.TrimPrefix(key, playerAddr+":")
809		parts = append(parts, `"`+skillId+`":`+val.(string))
810		return false
811	})
812	return "{" + strings.Join(parts, ",") + "}"
813}
814
815func ToolLife(playerAddr string) string {
816	var parts []string
817	toolLife.Iterate(playerAddr+":", playerAddr+":\xFF", func(key string, val any) bool {
818		toolId := strings.TrimPrefix(key, playerAddr+":")
819		parts = append(parts, `"`+toolId+`":`+val.(string))
820		return false
821	})
822	return "{" + strings.Join(parts, ",") + "}"
823}
824
825// ── Render ───────────────────────────────────────────────────────────────────
826
827func Render(_ string) string {
828	return ufmt.Sprintf("# GnoMoji Game Realm\n\nAdmin: `%s`\n\nPath: `gno.land/r/g138gdmwj6heduu3st796lw6qqdwylgaxf7ty5gz/game5`\n\nMine emoji coins on Gno.land!\n", admin.String())
829}
830
831// ── Helpers ──────────────────────────────────────────────────────────────────
832
833// deductBalances atomically checks all balances then deducts them.
834// Panics if any balance is insufficient — no partial deductions.
835// costs is a slice of [coinId, amount] pairs.
836func deductBalances(caller string, costs [][2]string) {
837	// First pass: verify all sufficient
838	for _, pair := range costs {
839		coinId, amtStr := pair[0], pair[1]
840		required, err := strconv.ParseUint(amtStr, 10, 64)
841		if err != nil || required == 0 {
842			continue
843		}
844		balKey := caller + ":" + coinId
845		have := uint64(0)
846		if balances.Has(balKey) {
847			have, _ = strconv.ParseUint(balances.Get(balKey).(string), 10, 64)
848		}
849		if have < required {
850			panic("insufficient " + coinId + ": need " + amtStr + " have " + strconv.FormatUint(have, 10))
851		}
852	}
853	// Second pass: deduct all
854	for _, pair := range costs {
855		coinId, amtStr := pair[0], pair[1]
856		required, err := strconv.ParseUint(amtStr, 10, 64)
857		if err != nil || required == 0 {
858			continue
859		}
860		balKey := caller + ":" + coinId
861		current, _ := strconv.ParseUint(balances.Get(balKey).(string), 10, 64)
862		balances.Set(balKey, strconv.FormatUint(current-required, 10))
863	}
864}
865
866func splitJsonArray(s string) []string {
867	var result []string
868	depth, start := 0, 0
869	for i := 0; i < len(s); i++ {
870		switch s[i] {
871		case '[', '{':
872			depth++
873		case ']', '}':
874			depth--
875		case ',':
876			if depth == 0 {
877				if e := strings.TrimSpace(s[start:i]); e != "" {
878					result = append(result, e)
879				}
880				start = i + 1
881			}
882		}
883	}
884	if e := strings.TrimSpace(s[start:]); e != "" {
885		result = append(result, e)
886	}
887	return result
888}