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