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

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