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

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