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

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