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

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