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.

garden.gno

123.52 Kb · 2608 lines
   1// Package garden is Gnotchi's pet realm, v3.
   2//
   3// WHY THIS EXISTS, in one paragraph, because a realm cannot be edited and the reason has to
   4// travel with the code. garden/v2 accepts `plant:0:<ts>` and `harvest:0:<ts>`, enforces the
   5// grow timer, moves the acorns -- and exposes no way to read a plot back. Its whole garden
   6// is write-only on chain. The app therefore hides the garden from any player who connects a
   7// wallet (app/js/home.js gates the garden card on `!gnome.chainProof`), and since harvesting
   8// is the only acorn income, a connected player's acorns can only ever go down. The live site
   9// now apologises for this in six places (app/js/config.js's WALLET_TRADEOFF_SENTENCE). There
  10// is no fix that is not a new realm. GardenState below is that fix; everything else here is
  11// either the same correction applied to eight more unreadable fields, or deliberately
  12// unchanged from v2.
  13//
  14// Full design, including every v2 limitation with line numbers, the migration argument, and
  15// the list of things deliberately left out: realms/garden/v3/DESIGN.md. Read that before
  16// changing anything here.
  17//
  18// GNOMES ARE TRADEABLE HERE, AND THE SALE COUNTER IS THE ONLY DOOR. TRADING-RULES.md records
  19// the owner's ruling of 2026-10-10 -- "gnomes AND items are tradeable, and a cut on every
  20// trade of both is a must" -- and MARKETPLACE-V1.md records the structural fact that decides
  21// the shape: a royalty implemented as a transfer hook sees transfers, not payments, so the
  22// only way to make the cut unavoidable is for the contract holding the asset to refuse every
  23// transfer path except its own sale function. That is what this realm does. There is no
  24// Approve, no TransferFrom, no gift, no burn, no withdrawal; a registered extension panics on
  25// every ledger movement unless Buy below is moving exactly that token right now. See the
  26// SALE section and DESIGN.md section 9. The fee and its recipient are the three SALE
  27// CONSTANTS at the very top of this file, decided by the owner on 2026-10-11 for the onyx
  28// testnet; init still gates them and refuses a value that could not work.
  29//
  30// DEPLOY STATUS IS DELIBERATELY NOT RECORDED IN THIS FILE, and neither is a claim about
  31// whether anyone has decided to deploy it. This comment ships on chain exactly as written
  32// and cannot be edited afterwards, so any status asserted here is frozen at authoring time
  33// and becomes a lie the moment it changes. garden/v2/gnomod.toml said "NOT deployed" in just
  34// such a line, was deployed hours later, and now carries that false claim on chain
  35// permanently. Live status lives in the repo root's STATUS.md and DECISIONS.md, which can
  36// still be corrected; the authority above both is the chain itself (`vm/qfile` on this path).
  37//
  38// ============================================================================
  39// THE ONE RULE THAT REPLACES v2's MISTAKE
  40// ============================================================================
  41//
  42// v2's bug class is not "a getter was forgotten". It is that a field was added to persisted
  43// state without a reader, on a contract that can never grow one. So:
  44//
  45//	EVERY PERSISTED FIELD HAS AN EXPORTED READER, AT DEPLOY TIME. THERE IS NO SECOND CHANCE.
  46//
  47// Anyone adding a field to `gnome` below must add it to a reader in the same change.
  48// TestEveryStoredFieldIsReadable pins the current field list against the current readers,
  49// value by value, so the failure is at least loud and local. Gno has no reflection to assert
  50// over, so that test cannot *detect* a new field -- the rule is still a discipline and this
  51// comment is not pretending otherwise.
  52//
  53// The corollary, which is why this file has no "extensible" blob getter: EXTENSIBILITY IS A
  54// FICTION ON AN IMMUTABLE CONTRACT. A versioned self-describing string cannot carry a field
  55// that did not exist at deploy time any more than a positional tuple can. This surface is
  56// designed for completeness now, not for growth later, because there is no later.
  57//
  58// ============================================================================
  59// SECURITY: NO recover(), EVER. Carried over from v1/v2/hollowmere/arena verbatim in intent.
  60// ============================================================================
  61//
  62// No code in this realm, now or later, may recover() around a call into ledger or token
  63// (grc721's PrivateLedger/Token). The sale-gate guarantee below is real, but it is NOT that
  64// the ledger write never happens -- it does, before the panic -- it is that an *uncaught*
  65// panic aborts the whole enclosing transaction, so that write (and everything else the
  66// transaction did) is discarded and never committed on chain. p/nt/grc721/v0/types.gno says
  67// this outright about extension hooks like saleGateExtension below: "Hooks run after the
  68// ledger write so they observe the post-movement state; only a caller that recovers from the
  69// panic keeps that write." A recover() anywhere between a ledger call and the transaction
  70// boundary -- even one added for an unrelated reason, even several call-frames away -- would
  71// silently convert every refused transfer into a permanent, successful one.
  72//
  73// This is not limited to code textually inside this file: a pure `p/` package this realm
  74// calls runs in garden's own call frame too, so a helper as innocuous as
  75// `Try(func(){ ledger.TransferFrom(...) })` imported from some future `p/` dependency would
  76// break the same guarantee just as surely as a recover() written here directly.
  77//
  78// The same rule applies to SaveDay's, ChooseType's and Buy's validate-then-commit pattern
  79// below: none recovers from its own internal panics, by design. Every check runs against a
  80// local value copy and nothing is written back until the whole call has validated; a panic
  81// before the commit leaves persisted state completely untouched, with no partial effect to
  82// undo. Buy extends the rule to money: the ledger write, the index writes and the two coin
  83// sends all happen after the last check that can fail, so a sale either happens whole or not
  84// at all -- there is no reachable state in which the gnome moved and the coins did not, or
  85// the reverse.
  86package garden
  87
  88import (
  89	"strconv"
  90	"strings"
  91	"time"
  92	"unicode"
  93	"unicode/utf8"
  94
  95	"gno.land/p/nt/avl/v0"
  96	"gno.land/p/nt/grc721/v0"
  97	"gno.land/p/nt/seqid/v0"
  98	"gno.land/p/nt/ufmt/v0"
  99
 100	"chain"
 101	"chain/banker"
 102	"chain/runtime/unsafe"
 103)
 104
 105// ============================================================================
 106// THE THREE SALE CONSTANTS. THE OWNER DECIDED THESE ON 2026-10-11 ("Go, yes proceed"), FOR THE
 107// ONYX TESTNET. NOTHING ELSE IN THIS FILE IS A NUMBER THE OWNER HAS NOT ALREADY SEEN.
 108// ============================================================================
 109//
 110// These were committed as placeholders that could not work until the owner chose them; they
 111// are now his values and they are the ones this realm deploys with. init() below still gates
 112// them -- assertFeeConfig refuses an invalid recipient, this realm's own address, the
 113// admin/deploy key, a rate outside 1..10000 and a floor outside 1..maxPriceUgnot -- so a
 114// future edit to a value that could not work still fails at deploy rather than succeeding
 115// with the wrong fee forever. TestDeployGateStillRefusesBadFeeValues proves the gate, and
 116// TestCommittedFeeConstantsAreTheOwnersDecision pins the values below to the decision.
 117//
 118// A realm cannot be edited after deploy, so these are permanent for THIS realm's life. They
 119// are testnet values: mainnet would be a fresh deploy with fresh numbers decided then (DESIGN.md
 120// section 7.5), and DECISIONS.md already requires a different key than the deploy key for
 121// anything real.
 122//
 123//	saleFeeRecipient  -- the bech32 address every sale's fee is sent to. Units: an address.
 124//	                     The owner chose the project's `gnotchi-opponent` key, which is NOT the
 125//	                     deploy/admin key (init refuses that one). It must not be this realm's
 126//	                     own address either: coins sent there are burned, because there is no
 127//	                     withdrawal path.
 128//	saleFeeRateBps    -- the proportional cut, in BASIS POINTS of the declared price
 129//	                     (100 bps = 1%). 500 is 5%, the figure ECONOMY.md:231 floated. Legal
 130//	                     range 1..10000.
 131//	saleFeeFloorUgnot -- the flat floor every sale pays regardless of price, in UGNOT
 132//	                     (1 GNOT = 1,000,000 ugnot). 1 GNOT. Legal range 1..maxPriceUgnot. This
 133//	                     is the whole defence against "list at 0, settle off-chain": a sale at a
 134//	                     declared price of zero still pays this much. Zero is refused because
 135//	                     the owner's ruling is a cut on EVERY trade.
 136//
 137// The buyer pays price + floor + price*rate; the seller receives exactly price.
 138//
 139// WHY THREE CONSTANTS AND NOT TWO: the brief for this work spoke of "the fee and the recipient
 140// address". A fee that is a percentage alone is dodged completely by listing at a price of 0,
 141// so a percentage cannot be "the fee" on its own; the floor is the part that makes the cut
 142// unavoidable and ECONOMY.md already describes the fee as floor plus percentage. Splitting the
 143// fee into its two numbers is the honest shape, not an extra decision.
 144const (
 145	saleFeeRecipient  = "g1ylpe9juk8cafgf9rt09wzuz85ug2lyfk5metyv" // gnotchi-opponent, owner's choice
 146	saleFeeRateBps    = int64(500)                                 // 5% of the declared price
 147	saleFeeFloorUgnot = int64(1_000_000)                           // 1 GNOT flat minimum per sale
 148)
 149
 150// ============================================================================
 151// Constants that are NOT tunable. See the ruleBands table below for the ones that are, and
 152// DESIGN.md section 5.3 for why the tunable/fixed split falls exactly here.
 153// ============================================================================
 154
 155const (
 156	collectionName   = "Gnotchi gnomes"
 157	collectionSymbol = "GNOME"
 158
 159	// adminAddress is the only address SetRule and FreezeRules will accept. Compiled in,
 160	// with no transfer function, deliberately -- an admin-transfer path is a second
 161	// authority path, and a lost admin key merely freezes the config, which is v2's status
 162	// quo and not a disaster.
 163	//
 164	// THIS IS THE DISPOSABLE ONYX TESTNET DEPLOY KEY (DECISIONS.md: "Never a mainnet key.").
 165	// It is correct for onyx and wrong for anywhere real. WHOEVER DEPLOYS THIS SOMEWHERE
 166	// THAT MATTERS MUST CHANGE THIS ONE CONSTANT FIRST; it cannot be changed afterwards.
 167	// See DESIGN.md section 5.6.
 168	adminAddress = "g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar"
 169
 170	// maxNameLen: unchanged from v1/v2 -- kept in step with app/js/config.js's NAME_MAX_LEN
 171	// by hand, because Gno has no way to read that file at build time.
 172	maxNameLen = 24
 173
 174	hourSeconds = int64(3600)
 175	daySeconds  = int64(86400)
 176
 177	// ---- values that must agree with app/js/rules.config.json and are NOT tunable. Each is
 178	// cited against the exact key it mirrors. Published by Rules() so a drift check against
 179	// rules.config.json is possible at all -- which it is not against v2, where nothing on
 180	// chain can be asked what any of these are. See DESIGN.md section 4.
 181	numPlots    = 3         // acorns.plots -- see DESIGN.md 4.4 for why this can never be tunable
 182	plantCost   = int64(1)  // acorns.plantCost
 183	feedCost    = int64(1)  // acorns.feedCost
 184	acornsStart = int64(10) // acorns.start
 185	meterMax    = int64(10) // meters.max
 186	patCheer    = int64(3)  // meters.patCheer
 187
 188	// hungryAtOrBelow is NOT tunable, and it is the one key I removed from the band table
 189	// after finding my own counterexample to the table's safety claim. It gates two different
 190	// things: planting (lower is kinder -- fewer refusals) and the `hungry` branch of moodNow
 191	// (where lower is NOT kinder). Because moodNow is first-match-wins with hungry checked
 192	// before sulky, and because hungry carries no XP modifier while sulky carries a penalty,
 193	// BEING HUNGRY CURRENTLY SHIELDS A NEGLECTED GNOME FROM THE SULKY PENALTY. Lowering this
 194	// threshold from 2 to 0 therefore moves a gnome with belly 1-2 and no pat for 72h out of
 195	// hungry and into sulky, costing it 10% of that day's care XP. LOOP.md does not ask for
 196	// this number to change, so removal costs nothing and shrinks the permanent admin surface.
 197	hungryAtOrBelow = int64(2) // meters.hungryAtOrBelow
 198
 199	// happyBonusPct is NOT tunable either, and the ratchet in SetRule is why. Its default (10)
 200	// is already its kindest value, so the only direction available to it was harsher --
 201	// removing a bonus. Under the ratchet it could therefore never move at all, and an
 202	// immovable "tunable" is pure audit surface. LOOP.md does want this at 0; that is a change
 203	// which takes something from a player, so it needs a v4 rather than an admin call. Stated
 204	// here rather than quietly dropped.
 205	happyBonusPct    = int64(10) // xp.happyBonus * 100
 206	happyWindowHours = int64(2)  // time.happyWindowHours
 207	sulkyAfterHours  = int64(72) // time.sulkyAfterHours
 208	maxLevel         = int64(20) // xp.maxLevel
 209	typeAtLevel      = int64(5)  // xp.typeAtLevel -- when ChooseType unlocks
 210
 211	xpPat            = int64(5)  // xp.pat
 212	xpFeed           = int64(5)  // xp.feed
 213	xpHarvestPerPlot = int64(10) // xp.harvestPerPlot
 214	xpTinkerTask     = int64(15) // xp.tinker
 215
 216	// maxActionsPerCall / maxBackfillHours: this realm's own choices, carried over from v2
 217	// unchanged, including v2's reasoning. maxBackfillHours was an explicit owner ruling --
 218	// "a player who ignores the game for a year can't just come back and claim it all in one
 219	// go" (DECISIONS.md) -- and v2 shipped on the back of it. Not tunable: widening it later
 220	// only ever grants more, but it also widens how far back a cared day (and so a streak)
 221	// can be claimed, and that is a game-rule decision rather than a balance dial.
 222	maxActionsPerCall = 32
 223	maxBackfillHours  = int64(48)
 224
 225	// caredWindowDays is the width of the cared-day bitmask. 64 days, 8 bytes, newest-first.
 226	// See DESIGN.md section 2.3 for why the chain stores cared DAYS and never computes the
 227	// STREAK: rules.js's streakFrom stays the single tested implementation of that rule.
 228	caredWindowDays = uint(64)
 229
 230	// maxGnomesPageSize bounds one GnomesPage call's own cost, the same way hollowmere/v1's
 231	// maxClaimantsPageSize does, so a caller cannot make one call scale with the whole realm.
 232	maxGnomesPageSize = 100
 233
 234	// ---- the sale. These are SHAPE constants, not economic ones: none of them is a price,
 235	// a rate or a destination, and none of them is a number the owner has been asked for.
 236	//
 237	// ugnotDenom is the only coin a sale is priced or settled in. ECONOMY.md and
 238	// MARKETPLACE-V1.md section 3: GNOT only, acorns never touch the market.
 239	ugnotDenom = "ugnot"
 240
 241	// maxPriceUgnot caps a declared price at 10^15 ugnot (a billion GNOT, more than exists).
 242	// It exists so that feeFor's arithmetic provably cannot overflow int64 for any legal
 243	// price at any legal rate, not to express an opinion about what a gnome is worth.
 244	maxPriceUgnot = int64(1_000_000_000_000_000)
 245
 246	// maxFeeRateBps: a share of a price cannot exceed the price. The upper bound the deploy
 247	// gate enforces on saleFeeRateBps, nothing more.
 248	maxFeeRateBps = int64(10_000)
 249
 250	// maxListingDays bounds how long a listing stands before Buy refuses it. A seller chooses
 251	// 1..30 days per listing. The hazard this bounds is irreversible: an immutable realm with
 252	// perpetual offers would let a gnome cared for another year sell at a price its owner set
 253	// and forgot. Relisting is one call; a sale at a forgotten price cannot be undone.
 254	maxListingDays = int64(30)
 255
 256	// maxListingsPageSize bounds one ListingsPage call, as maxGnomesPageSize does.
 257	maxListingsPageSize = 100
 258
 259	moodHungry  = "hungry"
 260	moodSulky   = "sulky"
 261	moodHappy   = "happy"
 262	moodContent = "content"
 263
 264	actionPat     = "pat"
 265	actionFeed    = "feed"
 266	actionPlant   = "plant"
 267	actionHarvest = "harvest"
 268	actionTinker  = "tinker"
 269
 270	typeGardener = "gardener"
 271	typeExplorer = "explorer"
 272	typeTinkerer = "tinkerer"
 273
 274	// ---- tunable rule keys. String keys, not Go identifiers, because SetRule takes one from
 275	// a transaction and the band table has to be a single list rather than a switch repeated
 276	// in three places. Names match rules.config.json's own names so a drift diff is mechanical.
 277	keyGrowHours       = "growHours"
 278	keyHarvestPerPlot  = "harvestPerPlot"
 279	keyFeedBelly       = "feedBelly"
 280	keyBellyDrop       = "bellyDropEveryHours"
 281	keyCheerDrop       = "cheerDropEveryHours"
 282	keyPatCooldown     = "patCooldownHours"
 283	keyTinkerCost      = "tinkerCost"
 284	keyDailyCareCap    = "dailyCareCap"
 285	keyLevelBase       = "levelBase"
 286	keyLevelStep       = "levelStep"
 287	keySulkyPenaltyPct = "sulkyPenaltyPct"
 288
 289	// kindEnd values for ruleBand below: which end of a band is KINDER to the player. This is
 290	// load-bearing in two places -- it fixes where the default must sit, AND it fixes which way
 291	// SetRule's ratchet will let the value travel. There is deliberately no "none": a band with
 292	// no kind direction has no safe ratchet, and SetRule rejects one rather than letting it
 293	// through unratcheted.
 294	kindMin = "min" // lower is kinder, so the default sits at max and may only fall
 295	kindMax = "max" // higher is kinder, so the default sits at min and may only rise
 296)
 297
 298// validHats/validCoats: unchanged from v1/v2. DESIGN.md section 7.3 records why appearance
 299// is still two validated fields and not the "open-ended list of parts" ECONOMY.md asks for:
 300// on an immutable realm an open-ended list is either validated against a compiled-in
 301// allowlist (exactly as closed as this) or it accepts arbitrary strings, which would undo
 302// validNameRune's three audit rounds one field over, since Render echoes into markdown.
 303var validHats = map[string]bool{
 304	"red":    true,
 305	"purple": true,
 306	"green":  true,
 307	"navy":   true,
 308}
 309
 310var validCoats = map[string]bool{
 311	"blue":   true,
 312	"teal":   true,
 313	"orange": true,
 314	"brown":  true,
 315}
 316
 317// validTypes: the three callings ChooseType accepts. All three are reachable in this realm,
 318// which is the point -- v2's assignType was called with explorer XP hard-coded to 0
 319// (garden/v2/garden.gno:823), so Explorer was permanently unreachable there, and for a
 320// connected player who never saw a garden or a tinker bench none of the three was. See
 321// DESIGN.md section 3.4.
 322var validTypes = map[string]bool{
 323	typeGardener: true,
 324	typeExplorer: true,
 325	typeTinkerer: true,
 326}
 327
 328// ============================================================================
 329// The tunable rules, their bands, and the one property that makes an admin defensible
 330// ============================================================================
 331
 332// ruleBand is one tunable number: its key, its deploy default, and the closed interval
 333// SetRule will accept. kindEnd records which end of that interval is kinder to the player,
 334// and is what TestEveryBandIsPlayerSafe checks the default against.
 335type ruleBand struct {
 336	key     string
 337	def     int64
 338	min     int64
 339	max     int64
 340	kindEnd string
 341}
 342
 343// ruleBands is the whole tunable surface of this realm. Nothing outside this table can be
 344// changed after deployment, by anyone, ever.
 345//
 346// WHY THIS TABLE EXISTS AT ALL, since no other realm in this project has one.
 347// DECISIONS.md records that the build brief gave garden and arena a Pause(), that an AI
 348// instruction overrode it, that consequently "no realm has an admin, a pause or a settable
 349// parameter, and every number is a frozen constant", and that "whether the next realms get
 350// an owner-settable config is an open decision for the owner, and it is cheap today".
 351// Meanwhile LOOP.md:89-92 already specifies NINE of the numbers below changing after the
 352// first ten testers answer, and LOOP.md:127-130 says to deploy nothing before they do. A v3
 353// that froze them would be obsolete on the day it shipped: that rebalance would cost a v4
 354// plus a second migration. So the numbers LOOP.md names are tunable, inside bands, in one
 355// direction only, and FreezeRules below makes the frozen outcome reachable too.
 356//
 357// WHY THE RISK IS SMALL, RESTATED NOW THAT GNOMES HAVE A PRICE. An earlier version of this
 358// paragraph said "this realm holds nothing of value"; with a sale function that is no longer
 359// the whole truth, so here is the exact truth instead. The realm holds no coins between
 360// transactions: Buy's banker is BankerTypeOriginSend, which can move at most the coins the
 361// buyer attached to that one call and dies with the call, and there is no RealmSend banker
 362// anywhere in this file. Gnomes DO now have a price, and the admin reaches none of them: no
 363// function here lets adminAddress list, cancel, buy, move, burn or re-price a gnome, change
 364// the fee, change the recipient, or pause sales. The only state the admin can reach is
 365// ruleValues, through SetRule, inside the bands, one way. There is still no theft available
 366// to a compromised admin; what it can do is rebalance the game toward the player.
 367//
 368// THE SAFETY PROPERTY, STATED EXACTLY, BECAUSE AN EARLIER AND LOOSER VERSION OF THIS COMMENT
 369// WAS FALSE AND SHIPPED NOWHERE ONLY BECAUSE AN AUDIT CAUGHT IT. Two parts, and the second
 370// part is the one that was missing:
 371//
 372//  1. Every band is one-directional: kindEnd records which end is kinder to the player, and
 373//     each default sits at the HOSTILE end, so the only place a value can go is toward the
 374//     player. TestEveryBandIsPlayerSafe pins that, per key.
 375//  2. SetRule RATCHETS: it rejects any value less kind than the CURRENT one, not merely any
 376//     value outside the band. Without this, a band being a closed interval meant it was
 377//     freely re-enterable from either side -- levelBase 50 -> 30 -> 50 was permitted, and
 378//     because levelFromXp is recomputed and written back on every SaveDay, that last call
 379//     made STORED LEVELS DROP on gnomes that already existed. See SetRule's own comment for
 380//     why that specific power was the one thing a redeploy could never grant, and for the
 381//     price the ratchet charges (there are no take-backs).
 382//
 383// Together: for this realm's whole life, a level can never go down, a meter can never drain
 384// faster, income can never fall, a cost can never rise and a gate can never tighten. That is
 385// an auditable property of this table plus that check, not a promise about anybody's
 386// intentions. TestTuningIsAOneWayRatchetTowardThePlayer exercises both directions of every
 387// key, which is the test the earlier version of this comment did not have.
 388//
 389// THE ONE RESIDUAL, NAMED RATHER THAN CLAIMED AWAY. moodNow is first-match-wins (hungry,
 390// sulky, happy, content) and only happy and sulky carry an XP modifier, so BEING HUNGRY
 391// SHIELDS A NEGLECTED GNOME FROM THE SULKY PENALTY. feedBelly and bellyDropEveryHours both
 392// raise effective belly, so either can lift a gnome with belly 1-2, unpatted for 72h, out of
 393// hungry and into sulky -- costing it sulkyPenaltyPct of that one day's capped care XP. It is
 394// bounded at 10% of at most 60 XP, it only touches a gnome already being neglected for three
 395// days, and TestRaisingFeedBellyCanCostANeglectedGnomeTenPercent demonstrates it rather than
 396// denying it. Both keys stay tunable because they are the core of LOOP.md's rebalance and the
 397// kindest changes available; hungryAtOrBelow, which was the largest contributor and is on
 398// nobody's list, was removed from this table instead. THE OWNER CAN CLOSE THE RESIDUAL
 399// ENTIRELY IN ONE CALL: SetRule(sulkyPenaltyPct, 0) is permitted by the ratchet and makes the
 400// flip cost exactly nothing. The perverse incentive underneath it (a gnome at belly 2 out-
 401// earning the same gnome at belly 3) exists at the DEFAULT config too and is a faithful port
 402// of app/js/rules.js's own ladder -- correcting it here would create precisely the
 403// chain/browser mood divergence this realm exists to stop, so it is ported, not fixed.
 404var ruleBands = []ruleBand{
 405	// Lower is kinder: default sits at the top of the band and may only fall.
 406	{keyGrowHours, 24, 12, 24, kindMin},      // acorns.growHours; LOOP.md wants 20
 407	{keyPatCooldown, 12, 6, 12, kindMin},     // time.patCooldownHours; LOOP.md wants 8
 408	{keyTinkerCost, 5, 1, 5, kindMin},        // acorns.tinkerCost; LOOP.md wants 3
 409	{keyLevelBase, 50, 30, 50, kindMin},      // xp.levelBase; LOOP.md wants 40
 410	{keyLevelStep, 25, 15, 25, kindMin},      // xp.levelStep
 411	{keySulkyPenaltyPct, 10, 0, 10, kindMin}, // xp.sulkyPenalty * 100; LOOP.md wants 0
 412
 413	// Higher is kinder: default sits at the bottom of the band and may only rise.
 414	{keyHarvestPerPlot, 3, 3, 6, kindMax},   // acorns.harvestPerPlot; LOOP.md wants 4
 415	{keyFeedBelly, 4, 4, 8, kindMax},        // meters.feedBelly; LOOP.md wants 6
 416	{keyBellyDrop, 3, 3, 8, kindMax},        // meters.bellyDropEveryHours; LOOP.md wants 4
 417	{keyCheerDrop, 6, 6, 12, kindMax},       // meters.cheerDropEveryHours; LOOP.md wants 8
 418	{keyDailyCareCap, 60, 60, 120, kindMax}, // xp.dailyCareCap
 419}
 420
 421// ============================================================================
 422// Storage
 423// ============================================================================
 424
 425// plot is one of this gnome's numPlots garden plots.
 426//
 427// TWO DELIBERATE DIFFERENCES FROM v2's plotState (garden/v2/garden.gno:164-167):
 428//
 429//  1. No `growing bool`. plantedAt == 0 IS the empty state. v2 carried both and had to keep
 430//     them consistent by hand in four places; one field cannot disagree with itself.
 431//  2. readyAt is STORED, not computed. This is the only derived value persisted anywhere in
 432//     this realm and it is deliberate: growHours is tunable, so a readiness check against
 433//     TODAY's growHours would silently change the ready time of a plot already in the
 434//     ground. Freezing it at plant time means a rule change can never reach backwards into
 435//     a plant a player has already made. 8 bytes per plot to make a tunable parameter safe.
 436type plot struct {
 437	plantedAt int64 // unix seconds; 0 means empty
 438	readyAt   int64 // plantedAt + growHours*hourSeconds, fixed AT PLANT TIME; 0 when empty
 439}
 440
 441// gnome is the whole persisted pet. EVERY FIELD HERE HAS AN EXPORTED READER -- see the
 442// package doc's one rule, and the table in DESIGN.md section 2.4 mapping field to reader.
 443//
 444// NOT here, on purpose: acorns. They live in `balances`, keyed by owner address, because
 445// ECONOMY.md:65-67 records the owner finding that hole himself -- "Acorns never travel with
 446// a gnome when it is sold. They belong to the player. Without this, money buys a gnome and
 447// inherits its acorns ... it is the kind of thing that is unfixable after deployment." Gnomes
 448// sell here, so this is no longer a theoretical distinction: Buy moves the gnome and no acorn
 449// moves to the buyer, because the purse was never on the gnome to begin with. The one acorn
 450// write Buy makes is TO THE SELLER -- it settles the garden's planted plots into the seller's
 451// own purse, because a planted plot is a claim on acorns and would otherwise have travelled.
 452//
 453// Meters are stored as (last-known value, the unix second it was set) per the brief, and
 454// meterNow works the current value out from elapsed time whenever anything reads it. Nothing
 455// ticks on this realm's clock, there is no scheduled job, and nothing here is written by
 456// anything other than a call the owning address itself signed.
 457type gnome struct {
 458	name      string
 459	hat       string
 460	coat      string
 461	level     int64
 462	gnomeType string // "" until ChooseType; never derived from XP -- see DESIGN.md 3.4
 463	adoptedAt int64  // unix seconds
 464
 465	belly   int64
 466	bellyAt int64
 467	cheer   int64
 468	cheerAt int64
 469
 470	// lastPatAt == 0 means "never patted". 0 is an unambiguous sentinel here (unlike in
 471	// rules.js, where timestamps are milliseconds and a test fixture can legitimately hold
 472	// 0): these are only ever written from a real chain's time.Now().Unix(), which is never
 473	// 0 on any chain that has left genesis.
 474	lastPatAt        int64
 475	lastHappyEventAt int64 // last pat or feed; the mood input v2 stored and never exposed
 476
 477	totalXp int64 // mood-adjusted, capped per UTC day; the only input to levelFromXp
 478
 479	// xpGarden/xpTinker/harvestCount are lifetime, UNCAPPED, mood-UNADJUSTED counters. In v2
 480	// the first two decided the type; here they decide nothing (ChooseType does) and exist
 481	// purely to be read. That is not waste: GARDEN-V3.md:95-98 argues that "earned marks" --
 482	// a muddy coat from gardening, a patched coat from long care -- are the appearance
 483	// feature actually worth having, and that if the underlying counters are exposed they
 484	// need no contract work at all, becoming a browser rendering decision over chain data.
 485	// So this realm ships counters and no appearance machinery.
 486	xpGarden     int64
 487	xpTinker     int64
 488	harvestCount int64
 489
 490	// dailyCareXp/dailyCareDay: the per-UTC-day accumulator dailyCareCap actually bounds,
 491	// carried over from v2's audit fix F1. Note this is STRICTER than app/js/rules.js, whose
 492	// careXpForDay caps a single call with no memory of what an earlier call the same day
 493	// already spent. v2's reading is the correct one and v3 keeps it; Progress() exposes the
 494	// spend so a client can subtract it instead of promising XP the chain then refuses.
 495	dailyCareXp  int64
 496	dailyCareDay int64
 497
 498	plots [numPlots]plot
 499
 500	lastTinkerAt int64 // unix seconds of the last completed tinker task; 0 = never
 501
 502	// Cared days. See DESIGN.md section 2.3 for the whole argument; the short version is that
 503	// the chain stores FACTS (which UTC days this address saved care on) and never the STREAK
 504	// RULE, which stays in app/js/rules.js's streakFrom -- the one implementation its own
 505	// tests already pin. Porting that rule here would mean reimplementing ISO-week rest-day
 506	// attribution permanently, and getting it subtly wrong would make the chain and the
 507	// browser disagree about the streak forever, which is the exact failure this realm exists
 508	// to stop.
 509	//
 510	// caredDayCount is the lifetime total and is what app/js/home.js's "Day N" means (it
 511	// increments per distinct day cared, not per day since adoption -- app/js/live.js:384-386).
 512	// lastCaredDay is a UTC day number; 0 means "never cared" and is unambiguous because unix
 513	// day 0 is 1970-01-01. caredMask bit i means "cared on day (lastCaredDay - i)".
 514	caredDayCount int64
 515	lastCaredDay  int64
 516	caredMask     uint64
 517
 518	// lastActionAt is the replay/ordering frontier: the unix second of the most recent action
 519	// any successful SaveDay call has ever applied. Initialised to adoptedAt at Adopt time,
 520	// so "every action strictly after lastActionAt" also means "no action at or before
 521	// adoption" for free. RESET TO THE SALE TIME BY Buy, for the same reason: a buyer must not
 522	// be able to backfill care dated before they owned the gnome, which would otherwise let
 523	// them claim the seller's last 48 hours. See SaveDay's own threat model.
 524	lastActionAt int64
 525
 526	// Provenance. acquiredAt is when the CURRENT owner got this gnome -- adoptedAt at Adopt,
 527	// the sale time after each Buy. saleCount is how many times it has changed hands. Both
 528	// are readable through Provenance(). Everything else on this struct travels with the
 529	// gnome when it sells, deliberately and whole -- except the contents of `plots`, which Buy
 530	// settles to the seller in acorns because a planted plot is a claim on acorns and acorns
 531	// never travel. See Buy's own comment for what the buyer inherits and why.
 532	acquiredAt int64
 533	saleCount  int64
 534}
 535
 536// listing is one gnome offered for sale. Keyed by tokenID in `listings`; at most one per
 537// gnome, and List replaces rather than stacks.
 538//
 539// buyer is the zero address for "anyone may buy", or one named address. A named buyer is the
 540// ONLY gift-shaped path in this realm, and it still pays the floor: see Buy.
 541type listing struct {
 542	seller     address
 543	priceUgnot int64
 544	buyer      address
 545	listedAt   int64
 546	expiresAt  int64
 547}
 548
 549// feeConfig is the three sale constants, as init captured them. See the top of the file.
 550type feeConfig struct {
 551	recipient  address
 552	rateBps    int64
 553	floorUgnot int64
 554}
 555
 556var (
 557	token  *grc721.Token
 558	ledger *grc721.PrivateLedger
 559
 560	nextID seqid.ID
 561
 562	gnomes     = avl.NewTree() // tokenID -> *gnome
 563	ownerIndex = avl.NewTree() // owner address -> tokenID
 564	balances   = avl.NewTree() // owner address -> int64 acorns (per PLAYER, not per gnome)
 565	ruleValues = avl.NewTree() // rule key -> int64
 566	listings   = avl.NewTree() // tokenID -> *listing
 567
 568	rulesFrozen bool
 569
 570	// fee is the three sale constants as init captured them. WRITTEN EXACTLY ONCE, IN init,
 571	// AND NOWHERE ELSE IN THIS FILE. There is no setter, no steward, no band and no freeze for
 572	// it because it is frozen from the first block: DESIGN.md section 9.6 argues why a fee is
 573	// not the kind of number the ratchet in SetRule is for.
 574	fee feeConfig
 575
 576	// testFeeOverride IS NIL IN EVERY DEPLOYED BUILD. It was introduced while the three sale
 577	// constants were still placeholders that init refused, so that the package's own test
 578	// suite could load at all; the constants are filled in now, and it stays for a narrower
 579	// reason: the sale tests settle fees to a throwaway fixture address and assert its balance,
 580	// and that address should not be the owner's real key. The test harness assigns this from
 581	// aa_harness_test.gno's init, which runs before this file's init because gno runs package
 582	// inits in filename order ("aa_" sorts before "garden"). On chain there is no such file:
 583	// the gno.land node stores only the MPFProd-filtered package, which drops every _test.gno
 584	// and _filetest.gno (vm/keeper.go sets MPUserProd; store.go persists the filtered copy), so
 585	// nothing can assign this and init sees the constants alone. Nothing in this file writes it.
 586	//
 587	// The failure direction is the safe one in both directions: if gno ever changed init
 588	// ordering, the suite would simply run on the real constants, which pass the same gate; it
 589	// can never make a deploy MORE permissive. TestCommittedFeeConstantsAreTheOwnersDecision
 590	// runs the committed constants through assertFeeConfig exactly as init does and pins them
 591	// to the owner's values; TestFeeInEffectIsTheHarnessFixture pins that the suite's coin
 592	// assertions are running on the fixture.
 593	testFeeOverride *feeConfig
 594
 595	// transferGate names the ONE token the grc721 extension will let move, and only while Buy
 596	// is moving it. It is "" at every transaction boundary: Buy sets it immediately before the
 597	// ledger call and clears it immediately after, and any panic in between aborts the
 598	// transaction and discards the set. It is therefore not persisted state in any meaningful
 599	// sense and has no reader; TestTransferGateIsClosedBetweenCalls pins that it is empty
 600	// after every sale.
 601	transferGate string
 602)
 603
 604func init(cur realm) {
 605	// Fail at deploy, loudly, rather than silently and forever. adminAddress is a compiled-in
 606	// string that this package's own gnomod.toml tells a deployer in capitals to edit before
 607	// deploying anywhere real. A typo fails CLOSED -- no caller can ever equal an invalid
 608	// address -- which is the safe direction but also the silent one: SetRule and FreezeRules
 609	// would be permanently unreachable while AdminState() published the broken value and
 610	// rulesFrozen stayed false forever, so the realm would advertise a live admin it did not
 611	// have. There is no way to notice that after deployment and no way to fix it. One line.
 612	if !address(adminAddress).IsValid() {
 613		panic("garden: adminAddress is not a valid address; fix the constant before deploying")
 614	}
 615
 616	// THE DEPLOY GATE. The three sale constants at the top of this file are checked against
 617	// the ranges and refusals in assertFeeConfig, so a deploy with a value that cannot work is
 618	// impossible rather than unlikely. In the test harness alone, testFeeOverride substitutes
 619	// fixture values -- see its comment.
 620	cfg := feeConfig{
 621		recipient:  address(saleFeeRecipient),
 622		rateBps:    saleFeeRateBps,
 623		floorUgnot: saleFeeFloorUgnot,
 624	}
 625	if testFeeOverride != nil {
 626		cfg = *testFeeOverride
 627	}
 628	assertFeeConfig(cfg, cur.Address())
 629	fee = cfg
 630
 631	token, ledger = grc721.NewToken(collectionName, collectionSymbol, 0, cur)
 632	ledger.RegisterExtension(&saleGateExtension{})
 633
 634	for _, b := range ruleBands {
 635		ruleValues.Set(b.key, b.def)
 636	}
 637}
 638
 639// assertFeeConfig is init's gate on the three sale constants, split out so a test can prove
 640// what it refuses, and prove that the committed constants pass it, without a failing init
 641// taking the whole test package down. Every message names the constant to edit. realmAddr is
 642// this realm's own address: fees sent there would be burned, because nothing here can ever
 643// withdraw them.
 644func assertFeeConfig(cfg feeConfig, realmAddr address) {
 645	if !cfg.recipient.IsValid() {
 646		panic("garden: DEPLOY GATE: saleFeeRecipient is not a valid address; fix the constant at the top of garden.gno before deploying")
 647	}
 648	if cfg.recipient == realmAddr {
 649		panic("garden: DEPLOY GATE: saleFeeRecipient is this realm's own address, and coins sent there are burned because there is no withdrawal path")
 650	}
 651	// The admin address is the deploy key, and the deploy key is the one address that would end
 652	// up here by default rather than by decision. The whole point of the gate is that the
 653	// recipient is chosen; MARKETPLACE-V1.md section 4.3 asks for exactly this assertion. Audit
 654	// finding Y-1/G-4 of 2026-10-11.
 655	if cfg.recipient == address(adminAddress) {
 656		panic("garden: DEPLOY GATE: saleFeeRecipient is the admin/deploy key; the fee recipient must be an address chosen for that purpose, not the key that happens to deploy")
 657	}
 658	if cfg.rateBps < 1 || cfg.rateBps > maxFeeRateBps {
 659		panic("garden: DEPLOY GATE: saleFeeRateBps must be between 1 and " + strconv.FormatInt(maxFeeRateBps, 10) + " basis points; fix the constant at the top of garden.gno before deploying")
 660	}
 661	if cfg.floorUgnot < 1 || cfg.floorUgnot > maxPriceUgnot {
 662		panic("garden: DEPLOY GATE: saleFeeFloorUgnot must be between 1 and " + strconv.FormatInt(maxPriceUgnot, 10) + " ugnot; fix the constant at the top of garden.gno before deploying")
 663	}
 664}
 665
 666// saleGateExtension replaces v1/v2's soulboundExtension. v2 panicked on EVERY transfer; this
 667// panics on every transfer EXCEPT the one Buy is performing right now, identified by
 668// transferGate holding that exact token id. The grc721 PrivateLedger is never exported and
 669// no Approve, SetApprovalForAll or TransferFrom is exposed by this realm, so Buy is the only
 670// code that can reach the ledger at all -- the gate is the second lock on a door that already
 671// has one, and it is the lock whose claim ships on chain where anyone can read it.
 672//
 673// See the package doc for why this relies on the transaction boundary rather than on hook
 674// ordering: the hook fires AFTER the ledger write, so a refused transfer's write is discarded
 675// only because the uncaught panic aborts the whole transaction. No recover(), ever.
 676type saleGateExtension struct{}
 677
 678func (saleGateExtension) ExtensionKind() string { return "sale-gate" }
 679
 680func (saleGateExtension) OnMint(to address, tid grc721.TokenID) {}
 681
 682func (saleGateExtension) OnTransfer(from, to address, tid grc721.TokenID) {
 683	if transferGate == "" || transferGate != tid.String() {
 684		panic("garden: a gnome can only change hands through Buy; every other transfer is permanently disabled")
 685	}
 686}
 687
 688func (saleGateExtension) OnBurn(tid grc721.TokenID) {
 689	panic("garden: gnomes cannot be burned")
 690}
 691
 692// ============================================================================
 693// Adopt
 694// ============================================================================
 695
 696// Adopt mints one gnome to the caller. Every validation v1 and v2 performed is carried over
 697// in the same order with the same messages -- the name charset allowlist, the invisible-letter
 698// denylist, the one-gnome-per-address rule, the OriginSend refusal -- because those checks
 699// are the output of three audit rounds and re-deriving them from memory is how they get lost.
 700//
 701// TWO THINGS CHANGE BECAUSE GNOMES CAN NOW BE SOLD. First, an address that has sold its gnome
 702// has no gnome, so it may adopt again -- "one gnome per address" is a statement about the
 703// present, not a lifetime count, and adoption is free for a fresh address anyway, so refusing
 704// it to a seller would punish only the honest. Second, the starting purse is granted ONCE PER
 705// ADDRESS, EVER: only if this address has never held acorns. A seller keeps their purse
 706// (acorns belong to the player, ECONOMY.md:65-67), so a re-adopting seller must not be
 707// topped back up to acornsStart, and a buyer who already has a purse from an earlier gnome
 708// keeps that one. Without this, sell-and-readopt would refill acorns for free.
 709//
 710// THE CUSTODY QUESTION IS CLOSED: ONLY A WALLET MAY ADOPT. realms/garden/NOTES.md recorded it
 711// in full and reserved it for the owner: cur.Previous().Address() is not restricted to a human
 712// wallet, so a realm that called Adopt would become the permanent, unrecoverable owner of a
 713// gnome, with no burn, admin or transfer to undo it. v1 and v2 left that open. On 2026-10-11
 714// the owner chose the accident case over the sponsor case -- an orphaned gnome permanently
 715// owned by some batching or multicall realm is a real person's lost gnome, and it can never
 716// be undone -- so Adopt now refuses any caller that is not a direct wallet call, with the same
 717// IsUserCall gate Buy already uses (DESIGN.md section 7.5).
 718//
 719// What the gate is, exactly, read from this toolchain's chain/runtime: IsUserCall() is
 720// "pkgPath == """, true for a `gnokey maketx call` (or a wallet's MsgCall) straight from a
 721// user and false for every code realm, INCLUDING the ephemeral realm a `gnokey maketx run`
 722// script executes in (that one is IsUserRun, not IsUserCall). So a sponsor realm, a batching
 723// realm and a run script are all refused, and none of them can ever be let back in on this
 724// realm: a v4 with an explicit "adopt for" path is how that door would reopen. The refusal
 725// comes first, before anything is read or written, so a refused caller gets this reason and
 726// not a later one.
 727//
 728// A consequence worth stating: since Buy is also wallet-only, NO REALM CAN EVER OWN A GNOME
 729// here, by construction. The audit's G-2 worry -- a realm owning and listing a gnome so that
 730// Buy pays a price to an address with no banker, where it is burned -- has no remaining path.
 731func Adopt(cur realm, name, hat, coat string) {
 732	if !cur.Previous().IsUserCall() {
 733		panic("garden: adopt must be called directly by a wallet, not through another realm; a gnome minted to a realm's address could never be recovered")
 734	}
 735
 736	// Refuse attached coins outright rather than strand them: Buy is the only function here
 737	// that takes payment, and nothing else has a way to return a coin. Two honest limits, both
 738	// carried from v1: the check is origin-scoped, not frame-scoped, so a transaction that
 739	// attaches coins to some OTHER realm in the same call chain makes this panic even though
 740	// garden received nothing; and it cannot prevent a direct bank send to garden's address
 741	// with no call at all.
 742	//
 743	// Said exactly, so nobody later mistakes it for recoverable: coins that reach this realm's
 744	// address by a direct bank send are BURNED, not stuck. Buy's banker is BankerTypeOriginSend,
 745	// which can move only the coins attached to that one Buy; there is no RealmSend banker, no
 746	// withdrawal path, no admin reach over coins, and none of those can be added. Do not send
 747	// GNOT here except as a Buy's exact total.
 748	if !unsafe.OriginSend().IsZero() {
 749		panic("garden: adopt accepts no coins; only Buy takes payment, and nothing else here can return them")
 750	}
 751
 752	caller := cur.Previous().Address()
 753	if !caller.IsValid() {
 754		panic("garden: adopt requires a valid caller address")
 755	}
 756
 757	if alreadyAdopted(caller) {
 758		panic("garden: this address has already adopted a gnome")
 759	}
 760
 761	if !validNameLength(name) {
 762		panic("garden: name must be 1-" + strconv.Itoa(maxNameLen) + " characters")
 763	}
 764
 765	if !validNameCharset(name) {
 766		panic("garden: name contains a character that is not an allowed letter, digit, space, hyphen or apostrophe")
 767	}
 768
 769	if !hasVisibleNameContent(name) {
 770		panic("garden: name must contain at least one visible character")
 771	}
 772
 773	if !validHats[hat] {
 774		panic("garden: invalid hat colour")
 775	}
 776
 777	if !validCoats[coat] {
 778		panic("garden: invalid coat colour")
 779	}
 780
 781	id := nextID.Next()
 782	tid := grc721.TokenID(id.String())
 783
 784	if err := ledger.Mint(caller, tid); err != nil {
 785		panic(err)
 786	}
 787
 788	now := time.Now().Unix()
 789
 790	// A freshly-adopted gnome starts with full meters. The brief specifies decay rates and
 791	// the starting acorn count but never a starting meter value; v2 chose full and nothing
 792	// since has argued against it. lastPatAt/lastHappyEventAt/lastTinkerAt/lastCaredDay all
 793	// start at 0 ("never"). lastActionAt starts at adoptedAt, which is what makes the replay
 794	// rule also forbid pre-adoption actions with no separate check.
 795	gnomes.Set(tid.String(), &gnome{
 796		name:         name,
 797		hat:          hat,
 798		coat:         coat,
 799		level:        1,
 800		gnomeType:    "",
 801		adoptedAt:    now,
 802		belly:        meterMax,
 803		bellyAt:      now,
 804		cheer:        meterMax,
 805		cheerAt:      now,
 806		lastActionAt: now,
 807		acquiredAt:   now,
 808	})
 809	ownerIndex.Set(caller.String(), tid.String())
 810	grantStartingPurseIfNew(caller)
 811
 812	chain.Emit(
 813		"Adopt",
 814		"owner", caller.String(),
 815		"tokenId", tid.String(),
 816		"name", name,
 817		"hat", hat,
 818		"coat", coat,
 819	)
 820}
 821
 822// alreadyAdopted: unchanged from v1/v2, and now also Buy's "one gnome per address" check.
 823// Both ownerIndex and token.BalanceOf are consulted, not either alone, so a disagreement
 824// between this realm's own index and the grc721 ledger can never be used to mint or buy a
 825// second gnome to one address IN THIS REALM. Buy keeps the two in step by writing ownerIndex
 826// in the same call as the ledger move.
 827//
 828// "ONE GNOME PER ADDRESS" IS TRUE PER REALM AND FALSE GLOBALLY, AND THAT IS PERMANENT. This
 829// paragraph is here rather than only in DESIGN.md because DESIGN.md is a repo file: editable,
 830// not deployed, and not available to whoever reads this realm's source off chain with
 831// `vm/qfile`. The caveat that most needs to reach a future realm's author was otherwise
 832// written in the one place that does not ship.
 833//
 834// garden/v2 is deployed and immutable at
 835// gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/garden/v2. It keeps its OWN ownerIndex
 836// and its OWN grc721 ledger, neither of which this function can see, and it stays fully
 837// callable forever -- there is no pause and no admin anywhere in it. So one address can hold a
 838// v2 gnome AND a v3 gnome simultaneously, with different names, hats, levels, callings and
 839// acorn balances, and v2's own SaveDay keeps accruing on the v2 one indefinitely. Nothing on
 840// chain reconciles the two and nothing ever can.
 841//
 842// The consequence that matters most to a future author: the deployed arena/v1 imports
 843// garden/v2 by absolute path, immutably, and reads GnomeByAddress live at battle-resolution
 844// time. For a dual adopter it therefore authoritatively reads the V2 gnome -- the state the
 845// app has stopped showing and this realm can never update. ChooseType here cannot touch it.
 846// ANY FUTURE REALM THAT ASSUMES A GLOBAL ONE-GNOME-PER-ADDRESS INVARIANT IS BUILDING ON A
 847// FALSE PREMISE. An arena/v2 written against v3 must pick one realm as the identity of record
 848// and say which.
 849//
 850// v3 deliberately does not import v2 to close this. See DESIGN.md section 6.3 for the full
 851// argument; the part of it that does not expire is that a hard import would pin this realm to
 852// one path on one chain and make it undeployable anywhere v2 is not -- the exact coupling
 853// arena/v1's own gnomod.toml has to warn its deployers about.
 854func alreadyAdopted(addr address) bool {
 855	if ownerIndex.Has(addr.String()) {
 856		return true
 857	}
 858
 859	balance, err := token.BalanceOf(addr)
 860	if err != nil {
 861		panic(err)
 862	}
 863	return balance > 0
 864}
 865
 866// ============================================================================
 867// SaveDay
 868// ============================================================================
 869
 870// saveAction is one parsed entry from a SaveDay call's action list.
 871type saveAction struct {
 872	kind string
 873	at   int64 // unix seconds, as claimed by the caller
 874	plot int   // only meaningful for actionPlant/actionHarvest
 875}
 876
 877// SaveDay is the brief's "one daily save": the day's pats, feeds, plants, harvests and tinker
 878// task submitted as a single list in one signed call.
 879//
 880// THE WIRE FORMAT IS UNCHANGED FROM v2, deliberately. It is proven on chain (TESTNET.md:51-60,
 881// heights 310579/310601/310681) and app/js/live.js already builds it, so changing it would
 882// cost app work and buy nothing. Semicolon-separated entries, each colon-separated:
 883//
 884//	"pat:<unixSeconds>"
 885//	"feed:<unixSeconds>"
 886//	"tinker:<unixSeconds>"
 887//	"plant:<plotIndex>:<unixSeconds>"
 888//	"harvest:<plotIndex>:<unixSeconds>"
 889//
 890// e.g. "pat:1791600000;feed:1791601000;plant:0:1791602000". An empty string is a legal no-op.
 891// A list, rather than a []Action parameter, because gno.land's own call-argument decoder
 892// (gno.land/pkg/sdk/vm/convert.go, convertArgToGno) can only build primitive scalars and
 893// []byte from a transaction -- there is no case there for a slice of structs, and no Gno-side
 894// type cleverness changes what the wire format can carry. No gnomeID parameter either: one
 895// gnome per address, no delegation, so there is no second gnome a caller could mean and no
 896// path by which this could legitimately act on anyone else's. A sale changes which address
 897// that is, and nothing else here: the seller's next SaveDay finds no gnome, and the buyer's
 898// must date every action after the sale (Buy resets lastActionAt).
 899//
 900// TIMESTAMPS ARE BLOCK TIME, NOT WALL CLOCK, AND A CLIENT THAT FORGETS THAT FAILS EVERY TIME.
 901// DECISIONS.md:500-515: the first on-chain SaveDay failed with "action timestamp cannot be in
 902// the future" because the client sent `date -u +%s` while block time ran ~3 seconds behind
 903// wall clock, measured not assumed, and "a browser sending Date.now() will therefore fail
 904// essentially always". The fix is client-side -- read the chain's own latest block time and
 905// subtract a small margin. This realm deliberately does NOT add a future tolerance: DECISIONS'
 906// own warning is that a tolerance near midnight would let a caller claim the next UTC day's
 907// XP allowance early.
 908//
 909// THREAT MODEL. actionsRaw is client-supplied and this realm is the only authority over it.
 910// Carried over from v2 whole, including both of its audit fixes:
 911//
 912//   - Replay, in whole or in part: every timestamp must be strictly greater than
 913//     lastActionAt, the gnome's own high-water mark. A replayed batch's first action is by
 914//     definition <= that mark.
 915//   - Duplicate actions inside one call: the same strict-increase rule applies action to
 916//     action, so two actions sharing a timestamp are rejected whatever kinds they are.
 917//   - Future-dated actions: every timestamp must be <= this call's own block time.
 918//   - Actions at or before adoption: subsumed by the replay check, since lastActionAt starts
 919//     at adoptedAt.
 920//   - Unlimited backfill after a long dormancy (v2's audit fix F2, an owner ruling):
 921//     maxBackfillHours closes this twice over. Any action older than the window is rejected
 922//     outright with a message a player can understand, AND the floor the replay check
 923//     enforces is itself clamped forward to now-maxBackfillHours before the loop runs, so the
 924//     bound does not rest on a single comparison a future edit could weaken.
 925//   - Inflated action counts: pat spacing bounds pats per day arithmetically regardless of
 926//     what the list claims (two a day at the default 12-hour cooldown, four if the owner
 927//     ratchets it to the band floor of 6 -- v2's version of this note said "more than two a
 928//     day is impossible", which was true of v2's fixed constant and is not true here), and
 929//     feed/plant/tinker are each gated by the caller's own acorn balance, which this exact set
 930//     of rules is the only thing that can increase. The economy is the rate limit. Even the
 931//     floor case is far under dailyCareCap, so nothing here is a way around the cap.
 932//   - A thousand actions in one list: rejected on the delimiter COUNT, before strings.Split
 933//     allocates anything proportional to the input (v2's audit fix F5).
 934//   - XP inflation via the daily cap (v2's audit fix F1): the whole call's raw care XP is
 935//     mood-adjusted, then clamped to whatever remains of dailyCareCap FOR THE UTC CALENDAR
 936//     DAY, tracked by the gnome's own accumulator rather than anything scoped to one call.
 937//
 938// ATOMICITY: every check runs against local copies -- `work` for the gnome, `acorns` for the
 939// balance -- taken at the top. Nothing is written back until the whole list has validated and
 940// applied cleanly, so any panic before the final commit leaves persisted state completely
 941// untouched. A plain value copy, not a recover()-and-rollback; see the package SECURITY note.
 942func SaveDay(cur realm, actionsRaw string) {
 943	if !unsafe.OriginSend().IsZero() {
 944		panic("garden: save day accepts no coins; only Buy takes payment, and nothing else here can return them")
 945	}
 946
 947	caller := cur.Previous().Address()
 948	if !caller.IsValid() {
 949		panic("garden: save day requires a valid caller address")
 950	}
 951
 952	tid, g, ok := gnomeForAddr(caller)
 953	if !ok {
 954		panic("garden: no gnome adopted for this address")
 955	}
 956
 957	now := time.Now().Unix()
 958	actions := parseActions(actionsRaw)
 959
 960	if len(actions) == 0 {
 961		chain.Emit("SaveDay", "owner", caller.String(), "tokenId", tid, "actions", "0", "xpApplied", "0")
 962		return
 963	}
 964
 965	// Whole-batch ordering/bounds/same-day validation, before any mutation. firstDay anchors
 966	// the "one calendar day per call" rule: a call spanning more than one UTC day is rejected
 967	// outright rather than guessed at how to split. earliestAllowed is the backfill floor, used
 968	// twice on purpose -- see the threat model above.
 969	earliestAllowed := now - maxBackfillHours*hourSeconds
 970
 971	firstDay := dayNumber(actions[0].at)
 972	prevAt := g.lastActionAt
 973	if prevAt < earliestAllowed {
 974		prevAt = earliestAllowed
 975	}
 976	for _, a := range actions {
 977		if a.at <= earliestAllowed {
 978			panic("garden: this action is more than " + strconv.FormatInt(maxBackfillHours, 10) + " hours old; care can only be backfilled for the last " + strconv.FormatInt(maxBackfillHours, 10) + " hours, not longer absences")
 979		}
 980		if a.at <= prevAt {
 981			panic("garden: action timestamp must be strictly after the last saved action")
 982		}
 983		if a.at > now {
 984			panic("garden: action timestamp cannot be in the future")
 985		}
 986		if dayNumber(a.at) != firstDay {
 987			panic("garden: every action in one SaveDay call must fall on the same UTC day")
 988		}
 989		prevAt = a.at
 990	}
 991
 992	// Every rule this function's own body needs is read ONCE here, before the apply pass,
 993	// rather than per action: one tree lookup each instead of one per action, on a loop that
 994	// can legitimately carry 32 entries.
 995	//
 996	// Precisely, because an earlier version of this comment overclaimed twice. It said "rule
 997	// values are read once" full stop, which is not true of the whole call -- levelFromXp
 998	// below reads levelBase and levelStep itself (once each, not per iteration). And it
 999	// justified the caching as protection against "a SetRule landing concurrently", which is a
1000	// threat that does not exist: a transaction does not interleave with another, so nothing
1001	// can land inside one SaveDay. The real and sufficient benefit is gas.
1002	bellyDrop := rule(keyBellyDrop)
1003	cheerDrop := rule(keyCheerDrop)
1004	feedBelly := rule(keyFeedBelly)
1005	patCooldown := rule(keyPatCooldown)
1006	growHours := rule(keyGrowHours)
1007	harvestPerPlot := rule(keyHarvestPerPlot)
1008	tinkerCost := rule(keyTinkerCost)
1009	sulkyPenalty := rule(keySulkyPenaltyPct)
1010	dailyCap := rule(keyDailyCareCap)
1011
1012	// Apply pass: mutate local copies only. work.plots is a fixed-size array (a value type),
1013	// so `work := *g` copies it in full and mutating work.plots[i] never touches g.plots[i]
1014	// until the explicit commit at the end. Acorns are a separate tree, so they get their own
1015	// local and their own single write-back.
1016	work := *g
1017	acorns := balanceOf(caller)
1018	rawCareXp := int64(0)
1019	tinkeredThisCall := false
1020
1021	for _, a := range actions {
1022		switch a.kind {
1023		case actionPat:
1024			if !canPat(work.lastPatAt, a.at, patCooldown) {
1025				panic("garden: pat must be at least " + strconv.FormatInt(patCooldown, 10) + " hours after the last one")
1026			}
1027			work.lastPatAt = a.at
1028			work.lastHappyEventAt = a.at
1029			work.cheer = clampMeter(meterNow(work.cheer, work.cheerAt, a.at, cheerDrop) + patCheer)
1030			work.cheerAt = a.at
1031			rawCareXp += xpPat
1032
1033		case actionFeed:
1034			if acorns < feedCost {
1035				panic("garden: not enough acorns to feed")
1036			}
1037			acorns -= feedCost
1038			work.belly = clampMeter(meterNow(work.belly, work.bellyAt, a.at, bellyDrop) + feedBelly)
1039			work.bellyAt = a.at
1040			work.lastHappyEventAt = a.at
1041			rawCareXp += xpFeed
1042
1043		case actionPlant:
1044			// Table 3.3: "Hungry: can't garden until fed." Carried over from v2 as blocking
1045			// new PLANTING only, never harvesting -- rule 9 says the gnome never loses
1046			// progress, and a ready harvest should not be strandable behind a hunger gate.
1047			// This gate exists only on chain; app/js/rules.js has no equivalent, which is why
1048			// hungryAtOrBelow is published by Rules() and why LOOP.md:26-29 records a live bug
1049			// where the client did not check and a whole day's save was rejected with gas paid.
1050			if meterNow(work.belly, work.bellyAt, a.at, bellyDrop) <= hungryAtOrBelow {
1051				panic("garden: too hungry to garden; feed first")
1052			}
1053			p := &work.plots[a.plot]
1054			if p.plantedAt != 0 {
1055				panic("garden: that plot is already planted")
1056			}
1057			if acorns < plantCost {
1058				panic("garden: not enough acorns to plant")
1059			}
1060			acorns -= plantCost
1061			p.plantedAt = a.at
1062			p.readyAt = a.at + growHours*hourSeconds
1063
1064		case actionHarvest:
1065			p := &work.plots[a.plot]
1066			if p.plantedAt == 0 {
1067				panic("garden: that plot has nothing to harvest")
1068			}
1069			// Against the plot's OWN stored readyAt, never against today's growHours. See
1070			// plot's doc comment: this is what makes growHours safe to tune.
1071			if a.at < p.readyAt {
1072				panic("garden: that plot is not ready to harvest yet")
1073			}
1074			p.plantedAt = 0
1075			p.readyAt = 0
1076			acorns += harvestPerPlot
1077			work.xpGarden += xpHarvestPerPlot
1078			work.harvestCount++
1079			rawCareXp += xpHarvestPerPlot
1080
1081		case actionTinker:
1082			if tinkeredThisCall {
1083				panic("garden: only one tinker task per day")
1084			}
1085			if work.lastTinkerAt != 0 && dayNumber(work.lastTinkerAt) == dayNumber(a.at) {
1086				panic("garden: already did a tinker task today")
1087			}
1088			if acorns < tinkerCost {
1089				panic("garden: not enough acorns to tinker")
1090			}
1091			acorns -= tinkerCost
1092			work.xpTinker += xpTinkerTask
1093			work.lastTinkerAt = a.at
1094			tinkeredThisCall = true
1095			rawCareXp += xpTinkerTask
1096
1097		default:
1098			// Unreachable: parseActions rejects any kind outside this set before we get here.
1099			panic("garden: unknown action kind")
1100		}
1101	}
1102
1103	// One mood snapshot for the whole call's raw care XP, matching app/js/rules.js's
1104	// careXpForDay, which also takes one mood for an entire day's total rather than
1105	// re-deriving it per action. The "now" for that snapshot is the LAST action's own
1106	// timestamp, against the state exactly as this call leaves it.
1107	latestActionAt := actions[len(actions)-1].at
1108	bellyAtEnd := meterNow(work.belly, work.bellyAt, latestActionAt, bellyDrop)
1109	mood := moodNow(bellyAtEnd, work.lastPatAt, work.lastHappyEventAt, work.adoptedAt, latestActionAt, hungryAtOrBelow)
1110
1111	adjustedXp := rawCareXp
1112	switch mood {
1113	case moodHappy:
1114		adjustedXp = adjustedXp * (100 + happyBonusPct) / 100
1115	case moodSulky:
1116		adjustedXp = adjustedXp * (100 - sulkyPenalty) / 100
1117	}
1118
1119	// dailyCareCap bounds a UTC CALENDAR DAY's total care XP, not one call's (v2's audit fix
1120	// F1: before it, two calls the same day got 60 each against a cap of 60). Reset the
1121	// accumulator the moment this call's own day differs from whatever day it covers, clamp to
1122	// what is actually left, and top it up by exactly that much -- never by the pre-clamp
1123	// amount.
1124	if work.dailyCareDay != firstDay {
1125		work.dailyCareDay = firstDay
1126		work.dailyCareXp = 0
1127	}
1128	remainingToday := dailyCap - work.dailyCareXp
1129	if remainingToday < 0 {
1130		remainingToday = 0
1131	}
1132	if adjustedXp > remainingToday {
1133		adjustedXp = remainingToday
1134	}
1135	work.dailyCareXp += adjustedXp
1136
1137	work.totalXp += adjustedXp
1138	work.level = levelFromXp(work.totalXp)
1139
1140	// No assignType. v2 derived the type from xpGarden/xpTinker with explorer hard-coded to
1141	// 0, which made Explorer unreachable and -- per DECISIONS.md:643-646 -- made the other two
1142	// unbalanced by construction, so "nearly everyone becomes a Gardener". ChooseType replaces
1143	// it. See DESIGN.md section 3.4.
1144
1145	recordCaredDay(&work, firstDay)
1146	work.lastActionAt = latestActionAt
1147
1148	// Commit. Both writes are in one transaction, so either both land or neither does -- an
1149	// uncaught panic between them discards the whole transaction's state diff, which is why
1150	// this realm never needs (and must never have) a recover().
1151	//
1152	// The balance goes FIRST on purpose: setBalance is the only write here that can refuse
1153	// (its negative-balance guard), so running it before `*g = work` means the refusal
1154	// happens before any in-place mutation of the stored gnome, rather than relying solely
1155	// on the transaction boundary to undo one.
1156	setBalance(caller, acorns)
1157	*g = work
1158	gnomes.Set(tid, g)
1159
1160	chain.Emit(
1161		"SaveDay",
1162		"owner", caller.String(),
1163		"tokenId", tid,
1164		"actions", strconv.Itoa(len(actions)),
1165		"xpApplied", strconv.FormatInt(adjustedXp, 10),
1166		"mood", mood,
1167	)
1168}
1169
1170// ============================================================================
1171// ChooseType
1172// ============================================================================
1173
1174// ChooseType sets this gnome's calling, once, permanently, at level typeAtLevel or above.
1175//
1176// THIS REPLACES v2's DERIVED TYPE, AND IT IS THE WHOLE FIX FOR "ADVERTISES SOMETHING
1177// UNREACHABLE". v2 called assignType(xpGarden, 0, xpTinker) -- a literal 0 for explorer,
1178// because it had no Visit call -- so Explorer could never be assigned to any gnome, ever; and
1179// because the app hides the garden and the tinker bench from a connected player, such a
1180// player reached level 5 with 0 and 0, tied three ways, and got "" instead. Four independent
1181// sources already said the answer is a chooser rather than a tally:
1182//
1183//   - app/js/rules.js's own assignType returns {choose: tied} on a tie, documented "Ties:
1184//     player chooses".
1185//   - garden/v2/garden.gno's own comment: "the brief says 'the player chooses', and this
1186//     version has no chooser endpoint".
1187//   - LOOP.md:99-101: "at level 5 the player CHOOSES the calling rather than having it
1188//     derived from tallies that can only ever say Gardener."
1189//   - DECISIONS.md:643-646: the tallies are unbalanced by construction, because gardening
1190//     funds the gnome's food and tinkering does not, so the derivation was never a choice.
1191//
1192// WHAT IT COSTS, stated rather than glossed: the type stops being evidence of what a player
1193// did and becomes evidence of what they picked. arena/v1's type edge then reflects a choice
1194// rather than a history -- strictly better than reflecting a history that could only ever
1195// have one answer, but a different claim, and anyone reading that edge should know it changed.
1196//
1197// One choice, no retrain. rules.config.json has a retrainDays key; this realm implements
1198// nothing for it (DESIGN.md section 7.4).
1199func ChooseType(cur realm, kind string) {
1200	if !unsafe.OriginSend().IsZero() {
1201		panic("garden: choose type accepts no coins; only Buy takes payment, and nothing else here can return them")
1202	}
1203
1204	caller := cur.Previous().Address()
1205	if !caller.IsValid() {
1206		panic("garden: choose type requires a valid caller address")
1207	}
1208
1209	tid, g, ok := gnomeForAddr(caller)
1210	if !ok {
1211		panic("garden: no gnome adopted for this address")
1212	}
1213
1214	// Order matters for the message a player sees: "already chosen" before the level check,
1215	// so a level-5 gnome that has already chosen is told the true reason rather than being
1216	// re-offered a choice it cannot make.
1217	if g.gnomeType != "" {
1218		panic("garden: this gnome's calling is already chosen and cannot be changed")
1219	}
1220
1221	// Recomputed from totalXp against the CURRENT curve, not read from g.level. g.level is
1222	// only refreshed on a SaveDay, so after a levelBase/levelStep tune a gnome can be eligible
1223	// by today's curve while its stored level still says otherwise -- and the bands only ever
1224	// move the curve in the player's favour, so the stored value is the one that can be unfairly
1225	// low. The recomputed level is written back here too, so GnomeByAddress (which arena reads)
1226	// stops disagreeing with the curve from this moment rather than from the next save.
1227	level := levelFromXp(g.totalXp)
1228	if level < typeAtLevel {
1229		panic("garden: a calling can only be chosen at level " + strconv.FormatInt(typeAtLevel, 10) + " or above")
1230	}
1231
1232	if !validTypes[kind] {
1233		panic("garden: a calling is one of \"" + typeGardener + "\", \"" + typeExplorer + "\" or \"" + typeTinkerer + "\"")
1234	}
1235
1236	work := *g
1237	work.gnomeType = kind
1238	work.level = level
1239	*g = work
1240	gnomes.Set(tid, g)
1241
1242	chain.Emit(
1243		"ChooseType",
1244		"owner", caller.String(),
1245		"tokenId", tid,
1246		"type", kind,
1247	)
1248}
1249
1250// ============================================================================
1251// SALE. The only way a gnome ever changes hands. DESIGN.md section 9.
1252// ============================================================================
1253//
1254// THE SHAPE IN ONE PARAGRAPH. The owner lists their gnome at a price in ugnot, for a number of
1255// days, optionally reserved for one named buyer. Anyone eligible calls Buy with EXACTLY
1256// price + fee attached, where fee = saleFeeFloorUgnot + price * saleFeeRateBps / 10000. In
1257// that one call the gnome moves to the buyer, the price goes to the seller, and the fee goes
1258// to saleFeeRecipient. The realm holds nothing afterwards that it did not hold before. There
1259// are no bids, no auctions, no escrow, no approvals, no gifts outside a reserved listing, no
1260// burn, no withdrawal, and no admin reach into any of it.
1261//
1262// WHY THE CUT CANNOT BE DODGED ON CHAIN -- AND EXACTLY HOW FAR THAT CLAIM GOES. A royalty as a
1263// transfer hook sees transfers, not payments: list at 0, settle off-chain, royalty is zero.
1264// Here (1) the fee is computed on a price the realm itself RECEIVES in coins in the same call,
1265// never on a declared number; (2) no other transfer path exists, so there is no venue to
1266// settle through; and (3) the floor is charged even at a declared price of 0, so declaring a
1267// low price and settling the rest off-chain saves the seller the PERCENTAGE but never the
1268// FLOOR. That last point is the honest limit: an off-chain side payment is invisible to any
1269// contract, and the floor is the entire defence against it. So "unavoidable" means: every
1270// change of hands pays at least the floor, in this realm, with no exception reachable by any
1271// caller. Selling the wallet's private key dodges everything, as it does for every asset on
1272// every chain, and no contract can see it.
1273//
1274// WHY IsUserCall ON Buy, AND WHAT IT COSTS FOREVER. Coins attached to a transaction belong to
1275// the realm the wallet called, so a realm in the middle could not pay the seller with them.
1276// chain/banker makes this structural: a BankerTypeOriginSend banker can only be constructed
1277// when cur.Previous().IsUserCall() is true. The price is permanent and recorded here rather
1278// than discovered later: no realm can ever buy a gnome. No auction realm settling on a
1279// player's behalf, no gas-sponsored purchase, no bundle -- and no `gnokey maketx run` script
1280// either, since an ephemeral run realm is IsUser() but not IsUserCall(). A wallet calls Buy
1281// directly, or nobody does. It is this, or a dodgeable cut.
1282//
1283// WHAT THE BUYER INHERITS. Everything except the garden's contents and the replay frontier.
1284// Name, hat, coat, level, totalXp, calling, the lifetime counters, the cared-day history, the
1285// raw meters and their timestamps, the pat/tinker cooldowns and this UTC day's care-XP spend
1286// all travel. The owner accepted "money can buy strength from another player" as the cost of
1287// the ruling (TRADING-RULES.md:161-165), and a levelled gnome's level IS the thing being
1288// bought. The history stays with the gnome because it is the gnome's history -- provenance,
1289// not the seller's property -- and because every alternative is worse: resetting the daily cap
1290// would let sell-and-rebuy double a day's XP; resetting cooldowns would let a buyer pat at
1291// once. Two things change hands differently. lastActionAt is set to the sale time, so the
1292// buyer cannot submit care dated before they owned the gnome. And the GARDEN IS SETTLED TO THE
1293// SELLER, not inherited: a planted plot is a claim on acorns, acorns belong to the player and
1294// never travel (ECONOMY.md:65-67, "hole 2"), and an audit showed that plots travelling WAS an
1295// acorn-transfer channel. See the settlement block in Buy. The three empty plots travel.
1296//
1297// ONE GNOME PER ADDRESS, AND A BUYER WHO ALREADY HAS ONE. Refused, before anything moves:
1298// "sell it first". Two players therefore cannot swap directly -- each must sell, and each sale
1299// pays the fee -- which is the rule working as intended rather than a gap in it. A seller is
1300// free to adopt again afterwards and keeps their acorn purse (see Adopt).
1301
1302// List offers the caller's own gnome for sale.
1303//
1304// priceUgnot may be 0: that is the gift shape, and the buyer still pays the floor. buyer is
1305// the zero address ("") for anyone, or one named address other than the seller. days is
1306// 1..maxListingDays. Listing again replaces the standing listing; it is how a price is
1307// changed, and because Buy demands the exact total of the listing it finds, a buyer who
1308// prepared coins for the old price is refused rather than charged the new one.
1309//
1310// Listing locks nothing. The seller keeps caring for the gnome; care only ever adds to what
1311// the buyer gets, and a listed gnome that goes hungry is the seller's own problem to price.
1312func List(cur realm, priceUgnot int64, buyer address, days int64) {
1313	if !unsafe.OriginSend().IsZero() {
1314		panic("garden: list accepts no coins; only Buy takes payment")
1315	}
1316
1317	caller := cur.Previous().Address()
1318	if !caller.IsValid() {
1319		panic("garden: list requires a valid caller address")
1320	}
1321
1322	tid, _, ok := gnomeForAddr(caller)
1323	if !ok {
1324		panic("garden: no gnome adopted for this address")
1325	}
1326
1327	if priceUgnot < 0 {
1328		panic("garden: a price cannot be negative")
1329	}
1330	if priceUgnot > maxPriceUgnot {
1331		panic("garden: price exceeds the maximum of " + strconv.FormatInt(maxPriceUgnot, 10) + " ugnot")
1332	}
1333	if days < 1 || days > maxListingDays {
1334		panic("garden: a listing lasts between 1 and " + strconv.FormatInt(maxListingDays, 10) + " days")
1335	}
1336	if buyer != address("") {
1337		if !buyer.IsValid() {
1338			panic("garden: reserved buyer is not a valid address; pass an empty address to let anyone buy")
1339		}
1340		if buyer == caller {
1341			panic("garden: you cannot reserve your own gnome for yourself")
1342		}
1343	}
1344
1345	now := time.Now().Unix()
1346	listings.Set(tid, &listing{
1347		seller:     caller,
1348		priceUgnot: priceUgnot,
1349		buyer:      buyer,
1350		listedAt:   now,
1351		expiresAt:  now + days*daySeconds,
1352	})
1353
1354	feeUgnot := feeFor(priceUgnot)
1355	chain.Emit(
1356		"List",
1357		"seller", caller.String(),
1358		"tokenId", tid,
1359		"priceUgnot", strconv.FormatInt(priceUgnot, 10),
1360		"feeUgnot", strconv.FormatInt(feeUgnot, 10),
1361		"totalUgnot", strconv.FormatInt(priceUgnot+feeUgnot, 10),
1362		"buyer", buyer.String(),
1363		"expiresAt", strconv.FormatInt(now+days*daySeconds, 10),
1364	)
1365}
1366
1367// Cancel withdraws the caller's own listing. Only the seller can; nobody else -- not the
1368// admin, not a reserved buyer -- can cancel or alter anyone's listing.
1369func Cancel(cur realm) {
1370	if !unsafe.OriginSend().IsZero() {
1371		panic("garden: cancel accepts no coins; only Buy takes payment")
1372	}
1373
1374	caller := cur.Previous().Address()
1375	if !caller.IsValid() {
1376		panic("garden: cancel requires a valid caller address")
1377	}
1378
1379	tid, _, ok := gnomeForAddr(caller)
1380	if !ok {
1381		panic("garden: no gnome adopted for this address")
1382	}
1383	if !listings.Has(tid) {
1384		panic("garden: your gnome is not for sale")
1385	}
1386
1387	listings.Remove(tid)
1388	chain.Emit("Cancel", "seller", caller.String(), "tokenId", tid)
1389}
1390
1391// Buy purchases the listed gnome tokenID, paying its seller and the fee recipient in the same
1392// call. The caller must be a wallet, must not own a gnome, must be the reserved buyer if there
1393// is one, and must attach EXACTLY the listing's price + fee in ugnot and nothing else.
1394//
1395// VALIDATE, THEN COMMIT -- AND THE COMMIT PHASE CANNOT FAIL. Every refusal below happens
1396// before the first write. After the "commit" line nothing remains that can panic on chain:
1397// the ledger move is to an owner we have just verified with a gate we have just opened; the
1398// two coin sends are for amounts we have just checked equal the envelope (so the
1399// BankerTypeOriginSend cap cannot refuse them), from this realm's own address (which the
1400// envelope has credited before this code runs), to addresses validated at List time and at
1401// init. Payment is therefore atomic with transfer by construction, and not only because an
1402// uncaught panic would abort the transaction -- though it would, and that is the second lock.
1403//
1404// NO REENTRANCY IS POSSIBLE AND THIS IS WHY, STATED SO IT CAN BE CHECKED RATHER THAN
1405// TRUSTED: the only calls Buy makes outside this file are grc721's TransferFrom (a /p/
1406// package running in this realm's own frame, whose only hook is saleGateExtension above) and
1407// banker.SendCoins (a native that credits a bank balance and runs no code at the recipient).
1408// Neither can call back into this realm. State is nonetheless fully written before the sends,
1409// so even if a future chain gave coin recipients a hook, re-entering Buy for this token would
1410// find no listing and a different owner.
1411//
1412// EXACTLY, NOT AT LEAST. Accepting more than the total would make this realm a bank for the
1413// difference, with no withdrawal path; accepting a different denomination would strand it.
1414// The abort returns the whole envelope to the buyer, so a wrong amount costs gas and nothing
1415// else. Listing() publishes the exact total a wallet must attach.
1416func Buy(cur realm, tokenID string) {
1417	// Direct wallet call only. chain/banker would refuse the OriginSend banker further down
1418	// anyway; checking here first gives the caller the real reason instead of a banker error.
1419	if !cur.Previous().IsUserCall() {
1420		panic("garden: buy must be called directly by a wallet, not through another realm; the coins attached to a transaction belong to the realm the wallet called")
1421	}
1422	buyer := cur.Previous().Address()
1423	if !buyer.IsValid() {
1424		panic("garden: buy requires a valid caller address")
1425	}
1426
1427	l, ok := listingFor(tokenID)
1428	if !ok {
1429		panic("garden: that gnome is not for sale")
1430	}
1431
1432	now := time.Now().Unix()
1433	if now > l.expiresAt {
1434		panic("garden: that listing has expired; the seller must list again")
1435	}
1436	if l.seller == buyer {
1437		panic("garden: you cannot buy your own gnome")
1438	}
1439	if l.buyer != address("") && l.buyer != buyer {
1440		panic("garden: that gnome is reserved for a different buyer")
1441	}
1442	if alreadyAdopted(buyer) {
1443		panic("garden: this address already has a gnome; one gnome per address, so sell it before buying another")
1444	}
1445
1446	// Total function: the listing's seller must still be the ledger's owner and the gnome must
1447	// exist. Both are invariants every path in this file maintains (nothing but Buy moves a
1448	// gnome, and Buy deletes the listing), so these are unreachable today; they are here so
1449	// this function is correct on its own terms rather than correct because of code elsewhere.
1450	tid := grc721.TokenID(tokenID)
1451	owner, err := token.OwnerOf(tid)
1452	if err != nil || owner != l.seller {
1453		panic("garden: that listing is stale; the seller no longer owns that gnome")
1454	}
1455	gv := gnomes.Get(tokenID)
1456	g, isGnome := gv.(*gnome)
1457	if !isGnome || g == nil {
1458		panic("garden: that listing names a gnome this realm does not hold")
1459	}
1460
1461	feeUgnot := feeFor(l.priceUgnot)
1462	total := l.priceUgnot + feeUgnot
1463	// Read here, above the commit line, so the commit phase below contains no tree lookup that
1464	// could (even unreachably) panic on a missing key: rule() panics on absence by design.
1465	harvestPerPlot := rule(keyHarvestPerPlot)
1466	send := unsafe.OriginSend()
1467	if !exactUgnot(send, total) {
1468		panic("garden: buy requires exactly " + strconv.FormatInt(total, 10) + ugnotDenom +
1469			" attached (" + strconv.FormatInt(l.priceUgnot, 10) + " price + " +
1470			strconv.FormatInt(feeUgnot, 10) + " fee); got " + coinsString(send))
1471	}
1472
1473	// ---- commit. Nothing below this line can fail on chain. See the function comment. ----
1474
1475	transferGate = tokenID
1476	if err := ledger.TransferFrom(l.seller, l.seller, buyer, tid); err != nil {
1477		panic(err)
1478	}
1479	transferGate = ""
1480
1481	ownerIndex.Remove(l.seller.String())
1482	ownerIndex.Set(buyer.String(), tokenID)
1483	listings.Remove(tokenID)
1484
1485	// Settle the garden to the SELLER as the gnome changes hands (same transaction; the ledger
1486	// move above and this block commit or abort together). Audit finding Y-2 of 2026-10-11: a
1487	// planted plot is a bearer claim on acorns, so if plots travelled, a fresh address could
1488	// plant three, list at 0 to a confederate who pays only the floor, and hand over 9-18
1489	// acorns -- an acorn-transfer channel in a realm whose on-chain rule is that acorns never
1490	// travel. So: a ready plot pays the seller harvestPerPlot (the harvest they grew, judged
1491	// against the plot's own readyAt exactly as SaveDay's harvest is), a growing plot refunds
1492	// the seller plantCost (the acorn they planted), and the buyer receives an empty garden.
1493	// Nothing is destroyed and nothing moves between players. This is a settlement, not play:
1494	// no XP, no harvestCount, no cared day -- strictly worse for a seller than harvesting first,
1495	// so nobody is paid to prefer it. The seller always has a purse (Adopt and Buy both grant one
1496	// on acquisition), both summands are non-negative, and harvestPerPlot was read above the
1497	// commit line, so nothing here can fail. The Sale event carries settledAcorns.
1498	settled := int64(0)
1499	for i := 0; i < numPlots; i++ {
1500		p := &g.plots[i]
1501		if p.plantedAt == 0 {
1502			continue
1503		}
1504		if now >= p.readyAt {
1505			settled += harvestPerPlot
1506		} else {
1507			settled += plantCost
1508		}
1509		p.plantedAt = 0
1510		p.readyAt = 0
1511	}
1512	if settled > 0 {
1513		setBalance(l.seller, balanceOf(l.seller)+settled)
1514	}
1515
1516	g.acquiredAt = now
1517	g.saleCount++
1518	g.lastActionAt = now
1519	gnomes.Set(tokenID, g)
1520
1521	grantStartingPurseIfNew(buyer)
1522
1523	bank := banker.NewBanker(banker.BankerTypeOriginSend, cur)
1524	me := cur.Address()
1525	if l.priceUgnot > 0 {
1526		bank.SendCoins(me, l.seller, chain.Coins{{Denom: ugnotDenom, Amount: l.priceUgnot}})
1527	}
1528	bank.SendCoins(me, fee.recipient, chain.Coins{{Denom: ugnotDenom, Amount: feeUgnot}})
1529
1530	chain.Emit(
1531		"Sale",
1532		"tokenId", tokenID,
1533		"seller", l.seller.String(),
1534		"buyer", buyer.String(),
1535		"priceUgnot", strconv.FormatInt(l.priceUgnot, 10),
1536		"feeUgnot", strconv.FormatInt(feeUgnot, 10),
1537		"feeRecipient", fee.recipient.String(),
1538		"settledAcorns", strconv.FormatInt(settled, 10),
1539	)
1540}
1541
1542// Listing is one gnome's standing offer, including the EXACT total a wallet must attach to
1543// Buy. found is false when the gnome is not listed. An expired listing is still returned,
1544// with its expiresAt, so a client can tell "expired" from "never listed"; Buy refuses it.
1545func Listing(tokenID string) (seller address, priceUgnot int64, buyer address, listedAtUnix, expiresAtUnix, feeUgnot, totalUgnot int64, found bool) {
1546	l, ok := listingFor(tokenID)
1547	if !ok {
1548		return address(""), 0, address(""), 0, 0, 0, 0, false
1549	}
1550	f := feeFor(l.priceUgnot)
1551	return l.seller, l.priceUgnot, l.buyer, l.listedAt, l.expiresAt, f, l.priceUgnot + f, true
1552}
1553
1554// TotalListings / ListingsPage: the board. Same shape and same reasoning as TotalGnomes /
1555// GnomesPage -- this project has paid for a missing enumerator once already. The page is
1556// tokenIDs, oldest key first; resolve each with Listing() and OwnerOfToken(). Expired
1557// listings are included until their seller lists again or cancels.
1558func TotalListings() int64 {
1559	return int64(listings.Size())
1560}
1561
1562func ListingsPage(offset, limit int) []string {
1563	if offset < 0 {
1564		panic("garden: offset must not be negative")
1565	}
1566	if limit <= 0 {
1567		panic("garden: limit must be positive")
1568	}
1569	if limit > maxListingsPageSize {
1570		limit = maxListingsPageSize
1571	}
1572
1573	ids := make([]string, 0, limit)
1574	listings.IterateByOffset(offset, limit, func(key string, value any) bool {
1575		ids = append(ids, key)
1576		return false
1577	})
1578	return ids
1579}
1580
1581// QuoteFee is what a sale at priceUgnot costs the buyer, before anyone lists anything.
1582func QuoteFee(priceUgnot int64) (feeUgnot, totalUgnot int64) {
1583	if priceUgnot < 0 || priceUgnot > maxPriceUgnot {
1584		panic("garden: price must be between 0 and " + strconv.FormatInt(maxPriceUgnot, 10) + " ugnot")
1585	}
1586	f := feeFor(priceUgnot)
1587	return f, priceUgnot + f
1588}
1589
1590// FeeRules publishes the fee exactly as this realm will charge it, for the same reason Rules()
1591// publishes the game numbers: the chain's own value is the one that applies, and a client that
1592// can read it has no reason to hold a copy. Everything here is frozen for the realm's life.
1593func FeeRules() string {
1594	return "feeRateBps=" + strconv.FormatInt(fee.rateBps, 10) +
1595		";feeFloorUgnot=" + strconv.FormatInt(fee.floorUgnot, 10) +
1596		";feeRecipient=" + fee.recipient.String() +
1597		";maxPriceUgnot=" + strconv.FormatInt(maxPriceUgnot, 10) +
1598		";maxListingDays=" + strconv.FormatInt(maxListingDays, 10)
1599}
1600
1601// Provenance is the reader for the two fields a sale adds to the gnome.
1602func Provenance(addr address) (adoptedAtUnix, acquiredAtUnix, saleCount int64, found bool) {
1603	_, g, ok := gnomeForAddr(addr)
1604	if !ok {
1605		return 0, 0, 0, false
1606	}
1607	return g.adoptedAt, g.acquiredAt, g.saleCount, true
1608}
1609
1610// ============================================================================
1611// Reads. One per cluster of stored state, and between them they cover every persisted field
1612// -- see the package doc's one rule and DESIGN.md section 2.4.
1613// ============================================================================
1614
1615// GnomeByAddress has the BYTE-IDENTICAL signature to garden/v2's, on purpose: anything
1616// already written against v2 -- arena/v1's own GnomeByAddress call, app/js/adventure-
1617// villagers.js, app/js/live.js's qeval tuple parser, which reads these eight values
1618// positionally -- repoints at this realm with a one-line import change and no rewrite.
1619//
1620// That matters because arena/v1 is deployed and imports garden/v2 immutably, so a gnome
1621// adopted here cannot use arena/v1 at all and battle needs an arena/v2. Keeping this
1622// signature stable is the one thing this realm can do to make that a one-line change rather
1623// than a port. See DESIGN.md section 6.3.
1624func GnomeByAddress(addr address) (tokenID, name, hat, coat string, level int64, gnomeType string, adoptedAtUnix int64, found bool) {
1625	tid, g, ok := gnomeForAddr(addr)
1626	if !ok {
1627		return "", "", "", "", 0, "", 0, false
1628	}
1629	return tid, g.name, g.hat, g.coat, g.level, g.gnomeType, g.adoptedAt, true
1630}
1631
1632// GardenState is the read garden/v2 does not have, and the reason this realm exists.
1633//
1634// plantedAtCsv and readyAtCsv are each numPlots comma-separated unix seconds in plot order,
1635// with 0 for an empty plot -- e.g. "1791600000,0,0" / "1791686400,0,0". Two parallel
1636// fixed-width lists rather than one encoded blob, because the app's qeval reader already
1637// splits plain strings and because a reader that has to parse a schema is a reader that can
1638// parse it wrongly.
1639//
1640// readyCount is how many plots are harvestable AT THIS MOMENT, computed by the realm against
1641// each plot's own stored readyAt. The caller could derive it, but only by also knowing the
1642// chain's current block time, which is exactly the thing a browser gets wrong (see SaveDay's
1643// comment on DECISIONS.md:500-515). So the chain answers it.
1644func GardenState(addr address) (plantedAtCsv, readyAtCsv string, readyCount int64, found bool) {
1645	_, g, ok := gnomeForAddr(addr)
1646	if !ok {
1647		return "", "", 0, false
1648	}
1649
1650	now := time.Now().Unix()
1651	planted := make([]string, 0, numPlots)
1652	ready := make([]string, 0, numPlots)
1653	for i := 0; i < numPlots; i++ {
1654		p := g.plots[i]
1655		planted = append(planted, strconv.FormatInt(p.plantedAt, 10))
1656		ready = append(ready, strconv.FormatInt(p.readyAt, 10))
1657		if p.plantedAt != 0 && now >= p.readyAt {
1658			readyCount++
1659		}
1660	}
1661	return strings.Join(planted, ","), strings.Join(ready, ","), readyCount, true
1662}
1663
1664// CareState is v2's CareState plus the two things it left out: lastHappyEventAt (the mood
1665// input v2 stored at its own line 197 and exposed nowhere, so the app substituted
1666// lastActionAt, which is a different number -- a harvest-only day advances lastActionAt and
1667// not lastHappyEventAt), and acorns now read from the player's balance rather than the gnome.
1668//
1669// belly and cheer are returned already decayed to time.Now(), as v2's were. MeterState below
1670// returns the raw stored pair for a caller that wants to run app/js/rules.js's own meterNow
1671// against this realm's state and check the two agree.
1672//
1673// The signature is NOT positionally compatible with v2's CareState, unlike GnomeByAddress
1674// above. Nothing cross-realm reads CareState -- only the app does -- and inserting
1675// lastHappyEventAt where it belongs was worth more than preserving a tuple shape for a single
1676// caller that has to change anyway.
1677func CareState(addr address) (belly, cheer, acorns, totalXp, lastPatAtUnix, lastHappyEventAtUnix, lastActionAtUnix int64, found bool) {
1678	_, g, ok := gnomeForAddr(addr)
1679	if !ok {
1680		return 0, 0, 0, 0, 0, 0, 0, false
1681	}
1682	now := time.Now().Unix()
1683	return meterNow(g.belly, g.bellyAt, now, rule(keyBellyDrop)),
1684		meterNow(g.cheer, g.cheerAt, now, rule(keyCheerDrop)),
1685		balanceOf(addr), g.totalXp, g.lastPatAt, g.lastHappyEventAt, g.lastActionAt, true
1686}
1687
1688// MeterState returns the RAW stored meter pairs, undecayed. v2 exposed only the decayed
1689// values, which is lossy in a way that matters here: a caller cannot check the chain's decay
1690// against app/js/rules.js's meterNow if it is only ever handed the chain's answer. With this,
1691// the browser can run its own formula over the chain's own inputs -- which is how a repeat of
1692// the mood divergence in DESIGN.md section 1.5 gets caught in a day instead of in a year.
1693func MeterState(addr address) (belly, bellyAtUnix, cheer, cheerAtUnix int64, found bool) {
1694	_, g, ok := gnomeForAddr(addr)
1695	if !ok {
1696		return 0, 0, 0, 0, false
1697	}
1698	return g.belly, g.bellyAt, g.cheer, g.cheerAt, true
1699}
1700
1701// Progress is the rest of the stored state: the lifetime counters, the tinker clock and this
1702// UTC day's care-XP spend. Every one of these is a field garden/v2 writes and cannot read
1703// back.
1704//
1705// dailyCareXpSpent is the one a client most needs and v2 most lacked: the chain caps care XP
1706// per UTC DAY and app/js/rules.js caps it per CALL, so a browser that does not subtract this
1707// will promise XP the chain then refuses. dailyCareDay says which UTC day that spend belongs
1708// to, so a client can tell "60 spent today" from "60 spent on a day that has since rolled over".
1709func Progress(addr address) (xpGarden, xpTinker, harvestCount, lastTinkerAtUnix, caredDayCount, dailyCareXpSpent, dailyCareDay int64, found bool) {
1710	_, g, ok := gnomeForAddr(addr)
1711	if !ok {
1712		return 0, 0, 0, 0, 0, 0, 0, false
1713	}
1714	return g.xpGarden, g.xpTinker, g.harvestCount, g.lastTinkerAt,
1715		g.caredDayCount, g.dailyCareXp, g.dailyCareDay, true
1716}
1717
1718// CaredDays returns the UTC day numbers this address saved care on, newest first, comma
1719// separated -- e.g. "20371,20370,20368". At most caredWindowDays of them; empty if never.
1720//
1721// THE STREAK RULE IS DELIBERATELY NOT HERE. app/js/rules.js's streakFrom is the single tested
1722// implementation of it and takes exactly this list (as "YYYY-MM-DD", one line of JS from a day
1723// number -- rules.js:233 already does that conversion). Porting its ISO-week rest-day
1724// attribution onto an immutable contract would mean two implementations of one rule, and
1725// getting it subtly wrong would make the chain and the browser disagree about the streak
1726// forever. The chain stores facts; the browser owns the rule. DESIGN.md section 2.3.
1727//
1728// The encoding (a uint64 mask relative to lastCaredDay) is internal and no caller needs to
1729// know it; TestCaredMaskRoundTrips pins it.
1730func CaredDays(addr address) (daysCsv string, found bool) {
1731	_, g, ok := gnomeForAddr(addr)
1732	if !ok {
1733		return "", false
1734	}
1735	return caredDaysCsv(g), true
1736}
1737
1738// Mood returns the realm's OWN mood for this gnome, right now.
1739//
1740// This exists because the chain's mood is what applies the XP modifier, and because v2 proves
1741// a divergence here is not hypothetical: v2 ported rules.js's mood faithfully, rules.js was
1742// then fixed so a never-patted gnome is "new" rather than "neglected", and the deployed realm
1743// and the shipped browser have returned different moods for the same gnome ever since, with
1744// no way to notice from outside. v3 matches the current rules.js (see moodNow) and publishes
1745// its answer so the next divergence is observable.
1746func Mood(addr address) (mood string, found bool) {
1747	_, g, ok := gnomeForAddr(addr)
1748	if !ok {
1749		return "", false
1750	}
1751	now := time.Now().Unix()
1752	belly := meterNow(g.belly, g.bellyAt, now, rule(keyBellyDrop))
1753	return moodNow(belly, g.lastPatAt, g.lastHappyEventAt, g.adoptedAt, now, hungryAtOrBelow), true
1754}
1755
1756// CollectionInfo / TotalSupply / BalanceOf / OwnerOfToken: the GRC721 read surface.
1757//
1758// THESE EXIST BECAUSE AN AUDIT FOUND THE ONE PLACE THIS REALM'S OWN RULE DID NOT HOLD. Every
1759// field of the gnome struct has a reader; `token` and `ledger` did not. They are persisted
1760// realm state (the token is created inside garden's own storage, so there is no separate
1761// grc721 realm an indexer could query instead), and before these four functions the only read
1762// of any of it was token.TotalSupply() interpolated into Render's markdown, where no caller
1763// can consume it as a typed value.
1764//
1765// What that cost, permanently, if it had shipped: no wallet could list the gnome, no indexer
1766// could enumerate holders, no other realm could verify ownership except through garden's own
1767// GnomeByAddress, and -- the sharpest one -- there was NO tokenID-to-owner reader at all,
1768// because ownerIndex is owner-to-tokenID only. A tokenID observed in an Adopt event could not
1769// be resolved back to an owner without brute-forcing GnomesPage. garden/v2 has the identical
1770// gap and can never close it.
1771//
1772// nextID is the one piece of persisted state with no direct reader, and that is deliberate
1773// rather than missed: it is a seqid counter equal to the number of adoptions (there is no
1774// burn, and a sale moves a token without minting one), so TotalSupply() below is the same
1775// number by construction. Recorded so the next reader does not have to work that out.
1776func CollectionInfo() (name, symbol string) {
1777	return collectionName, collectionSymbol
1778}
1779
1780func TotalSupply() int64 {
1781	return token.TotalSupply()
1782}
1783
1784// BalanceOf is 0 or 1 for every address, forever: one gnome per address, no burn, and Buy
1785// refuses a buyer who already holds one. Exposed anyway rather than replaced by a bool,
1786// because a GRC721 consumer expects this shape.
1787//
1788// An invalid address owns nothing, so 0 is the CORRECT answer here and not an error signal --
1789// this function deliberately cannot distinguish "invalid address" from "owns none", because
1790// there is nothing to distinguish. Acorns(addr) below is the reader that does draw a
1791// distinction (absent versus zero), and it draws it about something where the difference is
1792// real. An earlier version of this comment claimed the opposite of the line underneath it;
1793// corrected here, before it could ship and say that permanently.
1794func BalanceOf(addr address) int64 {
1795	n, err := token.BalanceOf(addr)
1796	if err != nil {
1797		return 0
1798	}
1799	return n
1800}
1801
1802// OwnerOfToken resolves a tokenID back to its owner -- the lookup that was impossible without
1803// it. found is false for a tokenID this realm never minted.
1804func OwnerOfToken(tokenID string) (owner address, found bool) {
1805	if !gnomes.Has(tokenID) {
1806		return address(""), false
1807	}
1808	o, err := token.OwnerOf(grc721.TokenID(tokenID))
1809	if err != nil {
1810		return address(""), false
1811	}
1812	return o, true
1813}
1814
1815// TotalGnomes / GnomesPage: the enumerator garden/v2 has no equivalent of.
1816//
1817// This project has already paid for a missing enumerator once. DECISIONS.md:780-791 records
1818// that arena/v1 "exposes no way to list challenges -- confirmed with vm/qfuncs against the
1819// deployed realm, not assumed from the source -- and a realm cannot be edited", so there is no
1820// challenge inbox and there cannot be one; "if an inbox is ever wanted, it is an arena/v2
1821// plus a migration". hollowmere/v1 learned that and shipped TotalClaimants/ClaimantsPage.
1822// garden/v2 did not, so no leaderboard, no village roster and no "who is playing" query is
1823// possible against it at all. Twenty lines, unavailable forever if omitted.
1824func TotalGnomes() int64 {
1825	return int64(ownerIndex.Size())
1826}
1827
1828// GnomesPage returns up to limit owner addresses in ownerIndex's own stable key order,
1829// starting at offset. limit is CLAMPED to maxGnomesPageSize rather than rejected, so a caller
1830// asking for too much still gets a gas-bounded page instead of an aborted call; a negative
1831// offset or a non-positive limit are caller mistakes with no sane page to return.
1832//
1833// Paging with offset 0, then offset+len(page), until a page shorter than its requested limit
1834// comes back, covers every owner exactly once with no gaps and no duplicates for any snapshot
1835// where the tree does not grow between calls. TestGnomesPagePagesEveryOwnerExactlyOnce proves
1836// that directly rather than assuming it from avl.Tree's doc comment.
1837func GnomesPage(offset, limit int) []string {
1838	if offset < 0 {
1839		panic("garden: offset must not be negative")
1840	}
1841	if limit <= 0 {
1842		panic("garden: limit must be positive")
1843	}
1844	if limit > maxGnomesPageSize {
1845		limit = maxGnomesPageSize
1846	}
1847
1848	owners := make([]string, 0, limit)
1849	ownerIndex.IterateByOffset(offset, limit, func(key string, value any) bool {
1850		owners = append(owners, key)
1851		return false
1852	})
1853	return owners
1854}
1855
1856// Acorns is the player's own acorn balance, read directly rather than only through
1857// CareState, because acorns are a PLAYER's property here and not a gnome's (ECONOMY.md:65-67)
1858// and the read should say so. An address that has never held a purse gets 0, false -- never
1859// a bare 0 that a caller could mistake for a real empty purse. A player who has SOLD their
1860// gnome still has a purse, and this says so: it is keyed on the purse, not on the gnome,
1861// because the purse outlives the gnome and is what a later Adopt or Buy will find waiting.
1862func Acorns(addr address) (acorns int64, found bool) {
1863	if !balances.Has(addr.String()) {
1864		return 0, false
1865	}
1866	return balanceOf(addr), true
1867}
1868
1869// ============================================================================
1870// Rules: publishing them, tuning them inside bands, and freezing them for good
1871// ============================================================================
1872
1873// Rules publishes every number this realm actually uses, as "key=value;key=value;...", fixed
1874// values first in the order of DESIGN.md's agreement table, then the tunables in ruleBands
1875// order.
1876//
1877// THIS IS THE PIECE v2 HAD NO ANSWER TO. v2 kept thirty-odd values hand-copied from
1878// app/js/rules.config.json with no reader, so no drift check between the chain and the
1879// browser was possible even in principle -- nothing on chain could be asked what growHours
1880// was. With this, anyone can: gnoweb, curl, or a node test that compares each key against
1881// rules.config.json and fails on any mismatch.
1882//
1883// AND IT FIXES THE DIRECTION OF CORRECTION, which is the part that matters on an immutable
1884// contract. v2's two copies could drift and neither was authoritative. Here the chain is
1885// authoritative and, for a tunable, is the only place a value can change -- so a client should
1886// READ this and use it, not hold a second copy and hope.
1887//
1888// WHAT IS NOT DONE, stated rather than implied: the node-side half of that check does not
1889// exist. The follow-up is one test comparing this output against rules.config.json, plus
1890// making app/js/rules-bridge.js prefer these values for a connected player. Neither is
1891// written. Until they are, this is a capability and not a check.
1892func Rules() string {
1893	pairs := []string{
1894		"plots=" + strconv.Itoa(numPlots),
1895		"plantCost=" + strconv.FormatInt(plantCost, 10),
1896		"feedCost=" + strconv.FormatInt(feedCost, 10),
1897		"acornsStart=" + strconv.FormatInt(acornsStart, 10),
1898		"meterMax=" + strconv.FormatInt(meterMax, 10),
1899		"hungryAtOrBelow=" + strconv.FormatInt(hungryAtOrBelow, 10),
1900		"happyBonusPct=" + strconv.FormatInt(happyBonusPct, 10),
1901		"patCheer=" + strconv.FormatInt(patCheer, 10),
1902		"happyWindowHours=" + strconv.FormatInt(happyWindowHours, 10),
1903		"sulkyAfterHours=" + strconv.FormatInt(sulkyAfterHours, 10),
1904		"maxLevel=" + strconv.FormatInt(maxLevel, 10),
1905		"typeAtLevel=" + strconv.FormatInt(typeAtLevel, 10),
1906		"xpPat=" + strconv.FormatInt(xpPat, 10),
1907		"xpFeed=" + strconv.FormatInt(xpFeed, 10),
1908		"xpHarvestPerPlot=" + strconv.FormatInt(xpHarvestPerPlot, 10),
1909		"xpTinker=" + strconv.FormatInt(xpTinkerTask, 10),
1910		"maxBackfillHours=" + strconv.FormatInt(maxBackfillHours, 10),
1911		"maxActionsPerCall=" + strconv.Itoa(maxActionsPerCall),
1912		"caredWindowDays=" + strconv.FormatInt(int64(caredWindowDays), 10),
1913	}
1914	for _, b := range ruleBands {
1915		pairs = append(pairs, b.key+"="+strconv.FormatInt(rule(b.key), 10))
1916	}
1917	return strings.Join(pairs, ";")
1918}
1919
1920// RuleBands publishes the tunable surface itself: "key=min..max" for every band, in the same
1921// order Rules() uses.
1922//
1923// Without this, "the admin can only tune inside bands" is a claim in a document. With it,
1924// anyone can read the actual limits off the chain and check what this realm will and will not
1925// accept, without trusting DESIGN.md, this comment, or the owner.
1926func RuleBands() string {
1927	pairs := make([]string, 0, len(ruleBands))
1928	for _, b := range ruleBands {
1929		pairs = append(pairs, b.key+"="+strconv.FormatInt(b.min, 10)+".."+strconv.FormatInt(b.max, 10))
1930	}
1931	return strings.Join(pairs, ";")
1932}
1933
1934// AdminState is who may tune, and whether tuning is over. Readable by anyone, deliberately:
1935// an admin nobody can see is worse than an admin.
1936func AdminState() (admin address, frozen bool) {
1937	return address(adminAddress), rulesFrozen
1938}
1939
1940// SetRule changes one tunable number, inside its band, if the rules are not frozen.
1941//
1942// Only adminAddress. No admin transfer. Nothing here can touch a gnome, a balance, an XP
1943// total or a level -- the only state this function can reach is ruleValues, and only for a key
1944// that is in ruleBands, and only to a value inside that key's band. See ruleBands' own comment
1945// for why this exists at all and why the bands make it defensible; DESIGN.md section 5 for
1946// the full argument, including the explicit note that this is the one decision in v3 the owner
1947// should actively approve or veto before deploy, and that vetoing it is a deletion rather than
1948// a redesign.
1949func SetRule(cur realm, key string, value int64) {
1950	if !unsafe.OriginSend().IsZero() {
1951		panic("garden: set rule accepts no coins; only Buy takes payment, and nothing else here can return them")
1952	}
1953
1954	caller := cur.Previous().Address()
1955	if caller != address(adminAddress) {
1956		panic("garden: only the admin can change a rule")
1957	}
1958
1959	if rulesFrozen {
1960		panic("garden: the rules are frozen and can never be changed again")
1961	}
1962
1963	b, ok := bandFor(key)
1964	if !ok {
1965		panic("garden: \"" + key + "\" is not a tunable rule; see RuleBands()")
1966	}
1967
1968	if value < b.min || value > b.max {
1969		panic("garden: " + key + " must be between " + strconv.FormatInt(b.min, 10) + " and " + strconv.FormatInt(b.max, 10))
1970	}
1971
1972	before := rule(key)
1973
1974	// THE RATCHET. This is the check that makes the one-directional claim actually true, and
1975	// it is here because an audit found that without it the claim was false after the second
1976	// tune. A band is a CLOSED INTERVAL, freely re-enterable from either side: bounding the
1977	// interval bounds where a rule may sit, not which way it may travel. So
1978	// SetRule(levelBase, 30) followed by SetRule(levelBase, 50) was permitted, and since
1979	// levelFromXp is recomputed from totalXp on every SaveDay and written back, that second
1980	// call made STORED LEVELS DROP for gnomes that already existed.
1981	//
1982	// That is the one capability redeploying could never give the owner -- a v4 is a fresh
1983	// namespace and cannot reach into v3's stored gnomes -- so it was exactly the power the
1984	// whole "this realm holds nothing of value, an admin can only rebalance" argument did not
1985	// cover. FreezeRules made it worse rather than better: an admin could push every key to
1986	// the harsh end of its band and then freeze there, permanently, with no unfreeze and no
1987	// SetRule left to undo it.
1988	//
1989	// With this check each rule is a monotone one-way ratchet toward the player, for the
1990	// realm's whole life. The price, stated plainly because it is real: THERE ARE NO
1991	// TAKE-BACKS. Tune growHours to 20 and 22 is gone forever. That is not a side effect of
1992	// the fix, it IS the property -- "can only ever move toward the player" and "can be moved
1993	// back" are the same sentence negated. An owner who wants a harsher number after trying a
1994	// kinder one needs FreezeRules and a v4, which is the honest path for a change that takes
1995	// something from a player.
1996	switch b.kindEnd {
1997	case kindMin:
1998		if value > before {
1999			panic("garden: " + key + " can only be lowered, never raised; it is " + strconv.FormatInt(before, 10) + " and tuning is a one-way ratchet toward the player")
2000		}
2001	case kindMax:
2002		if value < before {
2003			panic("garden: " + key + " can only be raised, never lowered; it is " + strconv.FormatInt(before, 10) + " and tuning is a one-way ratchet toward the player")
2004		}
2005	default:
2006		// Total rather than defensive: a band with no stated kind direction has no safe
2007		// ratchet, so it must not be tunable at all. Rejecting here means adding such a band
2008		// later fails loudly instead of silently escaping the ratchet.
2009		panic("garden: " + key + " has no safe tuning direction and must not be tunable")
2010	}
2011	ruleValues.Set(key, value)
2012
2013	chain.Emit(
2014		"SetRule",
2015		"key", key,
2016		"from", strconv.FormatInt(before, 10),
2017		"to", strconv.FormatInt(value, 10),
2018	)
2019}
2020
2021// FreezeRules ends tuning permanently. One admin-only, irreversible call; afterwards SetRule
2022// panics forever and this realm is exactly as frozen as garden/v2.
2023//
2024// This is what makes the whole tunable-config decision safe to defer rather than get right
2025// before deploy: the owner can deploy tunable, run Check A, apply LOOP.md's numbers and then
2026// freeze -- or freeze on day one and never tune. Both outcomes are reachable from this realm.
2027// Only one is reachable from v2.
2028//
2029// There is no unfreeze, on purpose. A reversible freeze is not a freeze.
2030func FreezeRules(cur realm) {
2031	if !unsafe.OriginSend().IsZero() {
2032		panic("garden: freeze rules accepts no coins; only Buy takes payment, and nothing else here can return them")
2033	}
2034
2035	caller := cur.Previous().Address()
2036	if caller != address(adminAddress) {
2037		panic("garden: only the admin can freeze the rules")
2038	}
2039
2040	if rulesFrozen {
2041		panic("garden: the rules are already frozen")
2042	}
2043
2044	rulesFrozen = true
2045	chain.Emit("FreezeRules", "rules", Rules())
2046}
2047
2048// ============================================================================
2049// Render
2050// ============================================================================
2051
2052// Render is the gnoweb page. Unlike v2's, it shows the garden -- which is the whole point of
2053// this realm, and a player should be able to see their plots without a client at all.
2054//
2055// path is bech32-validated before anything derived from it is echoed back into the page, the
2056// same guard v1, v2, hollowmere and arena all use, for the same reason: path is whatever a URL
2057// put after the realm path, so it is attacker-controlled, and gnoweb renders this return value
2058// as markdown. A rejected path is never reflected at all, which is strictly safer than
2059// escaping it.
2060func Render(path string) string {
2061	if path == "" {
2062		return ufmt.Sprintf(
2063			"# %s\n\nGnome collection. A gnome changes hands only through Buy, and every sale pays the fee below.\n\n"+
2064				"* **Total adopted**: %d\n* **For sale**: %d\n* **Fee**: `%s`\n* **Rules**: `%s`\n* **Tunable within**: `%s`\n",
2065			collectionName, token.TotalSupply(), TotalListings(), FeeRules(), Rules(), RuleBands(),
2066		)
2067	}
2068
2069	addr := address(path)
2070	if !addr.IsValid() {
2071		return ufmt.Sprintf("# %s\n\nNo gnome adopted for an invalid address.\n", collectionName)
2072	}
2073
2074	tid, g, ok := gnomeForAddr(addr)
2075	if !ok {
2076		return ufmt.Sprintf("# %s\n\nNo gnome adopted yet for `%s`.\n", collectionName, addr.String())
2077	}
2078
2079	gnomeType := g.gnomeType
2080	if gnomeType == "" {
2081		gnomeType = "(not yet chosen)"
2082	}
2083
2084	now := time.Now().Unix()
2085
2086	str := ufmt.Sprintf("# %s\n\n", g.name)
2087	str += ufmt.Sprintf("* **Owner**: %s\n", addr.String())
2088	str += ufmt.Sprintf("* **Token ID**: %s\n", tid)
2089	str += ufmt.Sprintf("* **Level**: %d\n", g.level)
2090	str += ufmt.Sprintf("* **Calling**: %s\n", gnomeType)
2091	str += ufmt.Sprintf("* **Look**: %s hat, %s coat\n", g.hat, g.coat)
2092	str += ufmt.Sprintf("* **Acorns**: %d\n", balanceOf(addr))
2093	str += ufmt.Sprintf("* **Belly**: %d/%d\n", meterNow(g.belly, g.bellyAt, now, rule(keyBellyDrop)), meterMax)
2094	str += ufmt.Sprintf("* **Cheer**: %d/%d\n", meterNow(g.cheer, g.cheerAt, now, rule(keyCheerDrop)), meterMax)
2095	belly := meterNow(g.belly, g.bellyAt, now, rule(keyBellyDrop))
2096	str += ufmt.Sprintf("* **Mood**: %s\n", moodNow(belly, g.lastPatAt, g.lastHappyEventAt, g.adoptedAt, now, hungryAtOrBelow))
2097	str += ufmt.Sprintf("* **Days cared**: %d\n", g.caredDayCount)
2098
2099	str += "\n## Garden\n\n"
2100	for i := 0; i < numPlots; i++ {
2101		p := g.plots[i]
2102		switch {
2103		case p.plantedAt == 0:
2104			str += ufmt.Sprintf("* **Plot %d**: empty\n", i)
2105		case now >= p.readyAt:
2106			str += ufmt.Sprintf("* **Plot %d**: ready to harvest\n", i)
2107		default:
2108			str += ufmt.Sprintf("* **Plot %d**: growing, ready in %d hours\n", i, (p.readyAt-now+hourSeconds-1)/hourSeconds)
2109		}
2110	}
2111
2112	// The listing, so an owner who forgot they listed sees it here without a client. Every
2113	// value is this realm's own stored data or an address it validated at List time; nothing
2114	// from the URL path reaches this section.
2115	if l, ok := listingFor(tid); ok {
2116		f := feeFor(l.priceUgnot)
2117		str += "\n## For sale\n\n"
2118		if now > l.expiresAt {
2119			str += "* **Status**: EXPIRED (the owner must list again)\n"
2120		} else {
2121			str += "* **Status**: for sale\n"
2122		}
2123		str += ufmt.Sprintf("* **Price**: %d ugnot to the seller\n", l.priceUgnot)
2124		str += ufmt.Sprintf("* **Buyer pays**: %d ugnot (%d price + %d fee)\n", l.priceUgnot+f, l.priceUgnot, f)
2125		if l.buyer != address("") {
2126			str += ufmt.Sprintf("* **Reserved for**: %s\n", l.buyer.String())
2127		} else {
2128			str += "* **Reserved for**: anyone\n"
2129		}
2130		str += "* **Garden**: NOT included -- planted plots are settled to the seller in acorns at the sale; the buyer receives three empty plots\n"
2131		str += ufmt.Sprintf("* **Expires**: %d (unix seconds)\n", l.expiresAt)
2132	}
2133
2134	return str
2135}
2136
2137// ============================================================================
2138// Helpers
2139// ============================================================================
2140
2141func gnomeForAddr(addr address) (tid string, g *gnome, found bool) {
2142	v := ownerIndex.Get(addr.String())
2143	if v == nil {
2144		return "", nil, false
2145	}
2146	tid, ok := v.(string)
2147	if !ok {
2148		return "", nil, false
2149	}
2150	gv := gnomes.Get(tid)
2151	if gv == nil {
2152		return "", nil, false
2153	}
2154	gp, ok := gv.(*gnome)
2155	if !ok {
2156		return "", nil, false
2157	}
2158	return tid, gp, true
2159}
2160
2161// balanceOf: a missing key is a zero balance, not an error. Only Adopt creates a key, so a
2162// missing key means "no gnome here", which every caller has already checked.
2163func balanceOf(addr address) int64 {
2164	v := balances.Get(addr.String())
2165	n, ok := v.(int64)
2166	if !ok {
2167		return 0
2168	}
2169	return n
2170}
2171
2172// setBalance refuses to store a negative balance. Nothing above should ever produce one --
2173// every spend is guarded by its own "not enough acorns" check -- so this is not a guard that
2174// is expected to fire; it is a guard that makes a future arithmetic mistake abort the
2175// transaction instead of silently minting a debt the economy has no concept of.
2176func setBalance(addr address, v int64) {
2177	if v < 0 {
2178		panic("garden: acorn balance can never go negative")
2179	}
2180	balances.Set(addr.String(), v)
2181}
2182
2183// grantStartingPurseIfNew gives an address acornsStart the FIRST time a gnome arrives in its
2184// hands, by Adopt or by Buy, and never again. See Adopt's comment: a seller keeps their
2185// purse, so neither re-adopting nor buying may refill it.
2186func grantStartingPurseIfNew(addr address) {
2187	if balances.Has(addr.String()) {
2188		return
2189	}
2190	balances.Set(addr.String(), acornsStart)
2191}
2192
2193// feeFor is the whole fee formula: floor + price * rate / 10000, rounded down.
2194//
2195// Written as (price/10000)*rate + ((price%10000)*rate)/10000 rather than price*rate/10000.
2196// The two are identical for every input (write price = 10000q + r; then floor(price*rate/
2197// 10000) = q*rate + floor(r*rate/10000) because q*rate is an integer), but the first form
2198// cannot overflow int64 for any legal input: price <= maxPriceUgnot = 10^15 and rate <=
2199// maxFeeRateBps = 10^4, so (price/10000)*rate <= 10^15 and the remainder term is < 10^4 * 10^4.
2200// The naive form would overflow at price*rate > 9.2*10^18, i.e. a 10^15 price at any rate
2201// above 9223 bps. TestFeeArithmeticMatchesTheNaiveFormula pins the identity.
2202func feeFor(priceUgnot int64) int64 {
2203	return fee.floorUgnot + (priceUgnot/10_000)*fee.rateBps + ((priceUgnot%10_000)*fee.rateBps)/10_000
2204}
2205
2206// exactUgnot is Buy's payment check: one coin, in ugnot, of exactly want. Not "at least".
2207func exactUgnot(send chain.Coins, want int64) bool {
2208	return len(send) == 1 && send[0].Denom == ugnotDenom && send[0].Amount == want
2209}
2210
2211// coinsString renders an envelope for a refusal message; "nothing" for an empty one.
2212func coinsString(c chain.Coins) string {
2213	if len(c) == 0 {
2214		return "nothing"
2215	}
2216	return c.String()
2217}
2218
2219func listingFor(tokenID string) (*listing, bool) {
2220	v := listings.Get(tokenID)
2221	l, ok := v.(*listing)
2222	if !ok || l == nil {
2223		return nil, false
2224	}
2225	return l, true
2226}
2227
2228// rule reads a tunable. A missing key is a bug in this file, not a runtime condition, so it
2229// panics rather than returning a silent zero -- a zero growHours or a zero levelBase would be
2230// a far worse outcome than an aborted call.
2231func rule(key string) int64 {
2232	v := ruleValues.Get(key)
2233	n, ok := v.(int64)
2234	if !ok {
2235		panic("garden: missing rule value for \"" + key + "\"")
2236	}
2237	return n
2238}
2239
2240func bandFor(key string) (ruleBand, bool) {
2241	for _, b := range ruleBands {
2242		if b.key == key {
2243			return b, true
2244		}
2245	}
2246	return ruleBand{}, false
2247}
2248
2249// validNameLength/validNameCharset/hasVisibleNameContent/validNameRune/invisibleLetterRunes:
2250// unchanged from v1 and v2, including v2's audit fix F3 (\uXXXX escapes rather than literal
2251// invisible glyphs, so the denylist is readable in an editor and cannot be silently emptied by
2252// a formatter that strips default-ignorable characters). See v1's own comments for the
2253// exhaustive scan proving the list complete against this toolchain's Unicode version, and
2254// realms/garden/NOTES.md for the open product question about combining marks -- that allowlist
2255// excludes every combining mark, which makes zalgo names structurally impossible and also makes
2256// NFD Latin, Devanagari, Thai, vocalised Hebrew and Arabic unspellable. That trade-off is
2257// recorded as the owner's to revisit and is carried forward here unchanged rather than
2258// silently re-decided.
2259func validNameLength(name string) bool {
2260	n := utf8.RuneCountInString(name)
2261	return n > 0 && n <= maxNameLen
2262}
2263
2264func validNameCharset(name string) bool {
2265	for _, r := range name {
2266		if !validNameRune(r) {
2267			return false
2268		}
2269	}
2270	return true
2271}
2272
2273func hasVisibleNameContent(name string) bool {
2274	for _, r := range name {
2275		if r != ' ' {
2276			return true
2277		}
2278	}
2279	return false
2280}
2281
2282var invisibleLetterRunes = map[rune]bool{
2283	'ᅟ': true, // HANGUL CHOSEONG FILLER
2284	'ᅠ': true, // HANGUL JUNGSEONG FILLER
2285	'ㅤ': true, // HANGUL FILLER
2286	'ᅠ': true, // HALFWIDTH HANGUL FILLER
2287}
2288
2289func validNameRune(r rune) bool {
2290	switch r {
2291	case ' ', '-', '\'':
2292		return true
2293	}
2294	if invisibleLetterRunes[r] {
2295		return false
2296	}
2297	return unicode.IsLetter(r) || unicode.IsDigit(r)
2298}
2299
2300// parseActions parses and validates SaveDay's wire format, enforcing maxActionsPerCall on the
2301// delimiter COUNT before strings.Split ever runs (v2's audit fix F5: Split allocates the full
2302// result slice before any length check could inspect it, so a megabyte of semicolons allocated
2303// half a million short-lived strings before being rejected. strings.Count scans the raw bytes
2304// without allocating a result, so the oversized case now costs nothing proportional to the
2305// input. The caller always paid their own gas for this, so it was never a cross-user DoS --
2306// the fix makes the claim true, it does not close a vulnerability).
2307// quoteAction renders an offending entry for a panic message, TRUNCATED. Without this, the
2308// four malformed-action panics below interpolated the caller's own bytes at full length, so a
2309// semicolon-free, colon-free megabyte passed both delimiter guards and then built a
2310// megabyte-long panic string -- one remaining path that allocated proportional to the input,
2311// which is precisely what this function's own comment claims cannot happen. Harmless (about
2312// 2x amplification of data already in the transaction, entirely caller-paid, and a transaction
2313// abort message is not a markdown sink) but it made the claim false, and the claim ships.
2314//
2315// 48 bytes is comfortably longer than any legal entry -- "harvest:2:1791600000" is 20 -- so a
2316// real player's error message is never truncated and only an abusive one is.
2317func quoteAction(part string) string {
2318	const maxQuoted = 48
2319	if len(part) > maxQuoted {
2320		return "\"" + part[:maxQuoted] + "...\" (truncated)"
2321	}
2322	return "\"" + part + "\""
2323}
2324
2325func parseActions(raw string) []saveAction {
2326	if raw == "" {
2327		return nil
2328	}
2329
2330	if n := strings.Count(raw, ";") + 1; n > maxActionsPerCall {
2331		panic("garden: too many actions in one call (max " + strconv.Itoa(maxActionsPerCall) + ")")
2332	}
2333
2334	// The COLON count, bounded for the same reason and in the same way -- and this half was
2335	// missing. v2's fix F5 (ported above) guards the ';' count before the outer Split, but the
2336	// INNER strings.Split(part, ":") below was unguarded: a single semicolon-free entry with a
2337	// megabyte of colons passes the check above with n == 1, and then allocates one string per
2338	// colon before len(fields) != 2 can reject it. That is precisely the allocation pattern F5
2339	// exists to eliminate, which made this function's own claim about allocating nothing
2340	// proportional to the input false for the inner split.
2341	//
2342	// The legal maximum is two colons per entry ("plant:<plot>:<ts>"), so the whole string can
2343	// never legitimately carry more than 2*maxActionsPerCall of them. Rejected on a byte scan
2344	// with no allocation, like the check above. As with F5 the caller always paid their own gas
2345	// for this, so it was never a cross-user DoS -- this makes the comment true rather than
2346	// closing a vulnerability.
2347	if strings.Count(raw, ":") > 2*maxActionsPerCall {
2348		panic("garden: malformed action list (too many fields)")
2349	}
2350
2351	parts := strings.Split(raw, ";")
2352
2353	actions := make([]saveAction, 0, len(parts))
2354	for _, part := range parts {
2355		fields := strings.Split(part, ":")
2356		switch fields[0] {
2357		case actionPat, actionFeed, actionTinker:
2358			if len(fields) != 2 {
2359				panic("garden: malformed action " + quoteAction(part))
2360			}
2361			actions = append(actions, saveAction{
2362				kind: fields[0],
2363				at:   parseUnixField(fields[1], part),
2364			})
2365		case actionPlant, actionHarvest:
2366			if len(fields) != 3 {
2367				panic("garden: malformed action " + quoteAction(part))
2368			}
2369			actions = append(actions, saveAction{
2370				kind: fields[0],
2371				plot: parsePlotField(fields[1], part),
2372				at:   parseUnixField(fields[2], part),
2373			})
2374		default:
2375			panic("garden: unknown action kind in " + quoteAction(part))
2376		}
2377	}
2378	return actions
2379}
2380
2381func parseUnixField(s, part string) int64 {
2382	v, err := strconv.ParseInt(s, 10, 64)
2383	if err != nil || v <= 0 {
2384		panic("garden: invalid timestamp in action " + quoteAction(part))
2385	}
2386	return v
2387}
2388
2389func parsePlotField(s, part string) int {
2390	v, err := strconv.Atoi(s)
2391	if err != nil || v < 0 || v >= numPlots {
2392		panic("garden: invalid plot index in action " + quoteAction(part))
2393	}
2394	return v
2395}
2396
2397// dayNumber is the UTC calendar day index for a unix-second timestamp. Integer division by
2398// 86400 lands exactly on 00:00 UTC boundaries, matching brief 3.1 with no timezone handling,
2399// because unix time is UTC by definition.
2400func dayNumber(unixSeconds int64) int64 { return unixSeconds / daySeconds }
2401
2402// recordCaredDay folds one cared UTC day into the gnome's lifetime count and its 64-day
2403// window. See the gnome struct's own comment for the storage shape and DESIGN.md section 2.3
2404// for why the chain records days and never computes the streak.
2405//
2406// This handles a day arriving AT OR BEFORE lastCaredDay even though SaveDay's replay rule
2407// (every action strictly after lastActionAt) makes that unreachable today. Two extra lines to
2408// make this function correct on its own terms rather than correct only because of an invariant
2409// enforced two hundred lines away. On a contract that can never be edited, a total function is
2410// worth more than the lines it costs -- and a reader of this function alone should not have to
2411// go and verify SaveDay's ordering rule to know it is sound.
2412func recordCaredDay(g *gnome, day int64) {
2413	if g.lastCaredDay == 0 {
2414		g.lastCaredDay = day
2415		g.caredMask = 1
2416		g.caredDayCount = 1
2417		return
2418	}
2419
2420	if day > g.lastCaredDay {
2421		shift := uint(day - g.lastCaredDay)
2422		if shift >= caredWindowDays {
2423			// Everything in the window is now older than the window. Go defines a shift of
2424			// >= the operand width on an unsigned value as 0, so `g.caredMask <<= shift` would
2425			// do this anyway -- written out because relying on that is a reader's trap.
2426			g.caredMask = 0
2427		} else {
2428			g.caredMask <<= shift
2429		}
2430		g.caredMask |= 1
2431		g.lastCaredDay = day
2432		g.caredDayCount++
2433		return
2434	}
2435
2436	// day <= lastCaredDay.
2437	back := uint(g.lastCaredDay - day)
2438	if back >= caredWindowDays {
2439		// Older than the window: it still happened, so it counts toward the lifetime total,
2440		// but there is no bit left to record it in.
2441		g.caredDayCount++
2442		return
2443	}
2444	bit := uint64(1) << back
2445	if g.caredMask&bit != 0 {
2446		// Already recorded. Two saves on one UTC day are one cared day.
2447		return
2448	}
2449	g.caredMask |= bit
2450	g.caredDayCount++
2451}
2452
2453// caredDaysCsv decodes the window, newest first.
2454func caredDaysCsv(g *gnome) string {
2455	if g.lastCaredDay == 0 {
2456		return ""
2457	}
2458	days := make([]string, 0, caredWindowDays)
2459	for i := uint(0); i < caredWindowDays; i++ {
2460		if g.caredMask&(uint64(1)<<i) == 0 {
2461			continue
2462		}
2463		days = append(days, strconv.FormatInt(g.lastCaredDay-int64(i), 10))
2464	}
2465	return strings.Join(days, ",")
2466}
2467
2468// meterNow ports app/js/rules.js's meterNow exactly: work the current value out from a
2469// last-known value, the unix second it was set, and how many whole dropEveryHours periods have
2470// elapsed since, clamped to [0, meterMax].
2471//
2472// A tunable decay rate re-derives history under the new rate, which is harmless precisely
2473// because meters saturate: the affected span is bounded in practice by the 48-hour backfill
2474// window, and a long-dormant gnome's belly is already clamped at 0 under either rate. See
2475// DESIGN.md section 5.4.
2476func meterNow(value, setAt, now, dropEveryHours int64) int64 {
2477	// Total rather than defensive, and held to this file's own stated standard: recordCaredDay
2478	// argues that "a reader of this function alone should not have to go and verify SaveDay's
2479	// ordering rule to know it is sound", and this function was the counter-example -- it
2480	// divided by a parameter whose non-zeroness was guaranteed only by a band table twelve
2481	// hundred lines away. Unreachable today (both decay bands have non-zero minima and the
2482	// ratchet only raises them), and a division panic would brick every read and every save at
2483	// once, so the guard costs three lines and removes a whole-realm failure mode from the
2484	// set of things a future band edit could cause.
2485	if dropEveryHours <= 0 {
2486		panic("garden: a meter decay rate must be positive")
2487	}
2488	if now < setAt {
2489		now = setAt
2490	}
2491	drops := (now - setAt) / (dropEveryHours * hourSeconds)
2492	return clampMeter(value - drops)
2493}
2494
2495func clampMeter(v int64) int64 {
2496	if v < 0 {
2497		return 0
2498	}
2499	if v > meterMax {
2500		return meterMax
2501	}
2502	return v
2503}
2504
2505// canPat ports app/js/rules.js's canPat. lastPatAt == 0 means never patted; see the gnome
2506// struct's comment for why 0 is an unambiguous sentinel here and is not in the JS.
2507func canPat(lastPatAt, now, cooldownHours int64) bool {
2508	return lastPatAt == 0 || now-lastPatAt >= cooldownHours*hourSeconds
2509}
2510
2511// moodNow ports app/js/rules.js's CURRENT moodNow, in brief table 3.3's first-match-wins order:
2512// hungry, then sulky, then happy, then content. "Asleep" is display-only and has no on-chain
2513// representation, exactly as in rules.js.
2514//
2515// THE adoptedAt FLOOR IS THE FIX FOR A DIVERGENCE v2 CANNOT BE FIXED FOR, and it is the reason
2516// this function takes a parameter v2's did not. v2 ported rules.js's mood as it then stood,
2517// where a never-patted gnome's "last attention" fell back to the unix epoch, so "no pat for 72
2518// hours" was trivially true and every brand-new gnome read as sulky -- v2's own comment flags
2519// this and notes rules.test.js never exercised it. rules.js:103-119 has since been fixed, for
2520// a reported bug: "EVERY newly adopted gnome was instantly sulky" and "the very first thing a
2521// new player read was '<name>'s in a huff'". v2 cannot be edited, so the deployed realm and the
2522// shipped browser now disagree about the mood of the same gnome, and the chain's answer is the
2523// one that applies the XP penalty. This matches the fixed version:
2524//
2525//	neverPatted -> floor "last attention" at adoptedAt, because a gnome has been cared for, by
2526//	definition, at the moment somebody chose it.
2527//
2528// rules.js also maxes in lastVisitReceivedAt. This realm has no visit mechanic (DESIGN.md
2529// section 7.1), so that input is permanently absent, and rules.js with lastVisitReceivedAt ===
2530// null gives the identical answer. The floor applies ONLY to a gnome that has genuinely never
2531// been patted: rules.js's own comment records that an earlier attempt applied it
2532// unconditionally and quietly made neglect unreachable, so a gnome last patted 73 hours ago
2533// still sulks here, as it should.
2534//
2535// hungryThreshold is a PARAMETER rather than a package-constant reference inside the body, so
2536// this function is pure in its arguments and its formula can be checked against rules.js
2537// without knowing any realm state. It is deliberately NOT named hungryAtOrBelow: that is the
2538// name of the package constant every caller passes, and shadowing it here would leave a reader
2539// of this body alone unable to tell whether the threshold is fixed or caller-supplied -- on a
2540// file whose stated standard is that reading one function should be enough. The older note on
2541// why the parameter exists at all:
2542//
2543// the threshold was once a rule(...) lookup inside the body, so this
2544// function is pure in its arguments. It used to read the rule itself while SaveDay had already
2545// cached that same value for the planting gate -- harmless today, but it made the answer depend
2546// on realm state the signature did not mention, which is exactly the kind of hidden input that
2547// makes a formula hard to check against rules.js.
2548func moodNow(belly, lastPatAt, lastHappyEventAt, adoptedAt, now, hungryThreshold int64) string {
2549	if belly <= hungryThreshold {
2550		return moodHungry
2551	}
2552
2553	lastAttention := lastPatAt
2554	if lastPatAt == 0 {
2555		lastAttention = adoptedAt
2556	}
2557	if now-lastAttention >= sulkyAfterHours*hourSeconds {
2558		return moodSulky
2559	}
2560
2561	if lastHappyEventAt != 0 && now-lastHappyEventAt <= happyWindowHours*hourSeconds {
2562		return moodHappy
2563	}
2564	return moodContent
2565}
2566
2567// xpToNext/totalXpForLevel/levelFromXp port app/js/rules.js's functions of the same name:
2568// levelBase + levelStep*level to reach the next level, maxLevel the ceiling. levelBase and
2569// levelStep are tunable, so a level is recomputed from totalXp on every save -- the bands only
2570// permit them to fall, so a level can rise from a tune and can never drop. arena/v1 reads this
2571// level for ranked eligibility, so a tune can make a gnome newly eligible and never newly
2572// ineligible. DESIGN.md section 5.4.
2573// xpToNextWith/totalXpForLevelWith take the curve's two numbers as ARGUMENTS rather than
2574// reading them per call, and levelFromXp below reads them exactly once.
2575//
2576// This is not style. The obvious shape -- xpToNext() reading rule(keyLevelBase) and
2577// rule(keyLevelStep) itself -- puts two avl lookups inside a loop that is inside another loop
2578// bounded by maxLevel, so one SaveDay paid on the order of 800 tree reads to work out a level
2579// that needs two numbers and some arithmetic. garden/v2 had the numbers as compile-time
2580// constants and paid nothing; making them tunable must not quietly cost that.
2581func xpToNextWith(level, base, step int64) int64 { return base + step*level }
2582
2583func totalXpForLevelWith(level, base, step int64) int64 {
2584	var t int64
2585	for l := int64(1); l < level; l++ {
2586		t += xpToNextWith(l, base, step)
2587	}
2588	return t
2589}
2590
2591// xpToNext/totalXpForLevel are the single-argument forms the tests and any future reader
2592// want, kept so the curve can be asked about without threading the two numbers by hand.
2593func xpToNext(level int64) int64 {
2594	return xpToNextWith(level, rule(keyLevelBase), rule(keyLevelStep))
2595}
2596
2597func totalXpForLevel(level int64) int64 {
2598	return totalXpForLevelWith(level, rule(keyLevelBase), rule(keyLevelStep))
2599}
2600
2601func levelFromXp(totalXp int64) int64 {
2602	base, step := rule(keyLevelBase), rule(keyLevelStep)
2603	level := int64(1)
2604	for level < maxLevel && totalXp >= totalXpForLevelWith(level+1, base, step) {
2605		level++
2606	}
2607	return level
2608}