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

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