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

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