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

97.69 Kb · 2316 lines
   1// Package garden is Gnotchi's pet realm, v5: the free, allowlisted, drand-rolled, hold-many
   2// realm MINT.md designs end to end. It inherits garden/v4's machinery -- the SaveDay shape,
   3// meters, garden plots, levels, and the List/Buy/Cancel trading surface -- but NOT v4's
   4// invariants (MINT.md section 6). garden/v4 is live, immutable and untouched by this file; its
   5// two gnomes stay exactly where they are forever (MINT.md section 6, BATCH-ONE.md section 0).
   6//
   7// WHAT CHANGES FROM v4, IN ONE TABLE, EACH CITED TO THE DOCUMENT THAT DECIDED IT:
   8//
   9//   - Adopt(name, hat, coat, eyes, nose) is GONE. Nothing is chosen by a caller any more.
  10//     DECISIONS.md 2026-10-15 deprecates the DIY picker as a product direction; MINT.md
  11//     section 1a/section 2 replace it with a roll.
  12//   - An ALLOWLIST gates who may ever claim (MINT.md section 11: "gate issuance, never the
  13//     secondary market"). Admin-managed, additive and removable, never touching the mint
  14//     itself once a claim has spent its roll.
  15//   - ONE ROLL PER WALLET, FOREVER, enforced independently of whether the resulting gnome is
  16//     still held (MINT.md section 1a/section 2.3). hasRolled is permanent; nothing in this file
  17//     clears it, and selling the gnome does not refund the roll.
  18//   - FREE. Claim refuses coins exactly as v4's Adopt refuses them (MINT.md section 1a/2.5,
  19//     GAMES.md rule 6: "random when earned, exact when bought... free: random is allowed").
  20//   - THE ROLL IS TWO-PHASE, SETTLED BY DRAND (MINT.md section 10, the load-bearing finding of
  21//     this whole file). A single-transaction roll is peekable two ways, verified against
  22//     gno.land's own source and binary rather than assumed from Ethereum's model: vm/qeval can
  23//     call a crossing function for free with no signature, and `gnokey maketx call -simulate
  24//     only` previews a real signed call against the last committed block with no gas and no
  25//     broadcast. Neither tool is the cause; the cause is that gno.land exposes NO on-chain
  26//     entropy at all -- chain/runtime has ChainHeight and nothing else, math/rand is PCG seeded
  27//     (0,0) on every node forever, and there is no shipped VRF -- so any single-transaction
  28//     formula, however it is dressed up, is something a caller can evaluate offline for every
  29//     block it might land in before ever deciding to broadcast. The fix MINT.md section 10.5
  30//     settles on, and this file builds, is a commit/reveal split across this project's own
  31//     deployed drand verifier: Claim spends the one-time roll and pins a ROUND THAT DOES NOT
  32//     EXIST YET; Settle, permissionless, computes the result once that round is actually
  33//     published. One player signature, not two -- the player signs Claim only.
  34//   - PARTS FROM A WEIGHTED TABLE (ECONOMY.md section 3), not a player's choice, with three
  35//     named one-of-ones (Hob, Thistle, Wick -- BATCH-ONE.md section 7) capped at one each.
  36//   - COUNTED RARITY. Every part's population is tracked on chain and readable by anyone --
  37//     MINT.md section 13's "nine gnomes in the world have this nose" has to be a chain fact,
  38//     and EyesCount/NoseCount/HatCount/CoatCount below are that fact, not an app claim layered
  39//     on top.
  40//   - MINT ONE, HOLD MANY, PLAY ONE (MINT.md section 2a). Buy's one-gnome-per-address refusal is
  41//     GONE -- deliberately, and only from Buy, never from Claim (see Claim's hasRolled gate,
  42//     which is the cap that still matters). An address may hold any number of gnomes; exactly
  43//     one is ever AWAKE, tracked by activeToken, and only the awake one can ever gain a cared
  44//     day, a streak, a level or XP. SaveDay and ChooseType resolve through activeToken, never
  45//     through "the" gnome for an address, because there no longer is one.
  46//   - SWITCHING (Wake) is free, at most once per UTC day (MINT.md section 2a), the same
  47//     day-boundary discipline SaveDay's own dailyCareDay already uses.
  48//   - THE SECONDARY MARKET IS OPEN: Buy/List/Cancel carry over v4's shape and fee (MINT.md
  49//     section 11), with no allowlist on buying and no hold cap.
  50//   - THE LOCK: the three named one-of-ones cannot be listed until they have lockCaredDays
  51//     cared days; every ordinary gnome trades from day one (MINT.md section 12/13, "Option A" --
  52//     lock only the named one-of-ones, recommended there and NOT YET CONFIRMED BY THE OWNER,
  53//     which is why lockCaredDays is one named constant and nothing else in this file assumes
  54//     its value). A gnome's own caredDayCount never decreases and is not reset by Buy, so once
  55//     a named gnome unlocks it stays unlocked through every future sale.
  56//   - A GENESIS GNOME, minted to this realm's own address at deploy, non-transferable, never
  57//     listable, separate from the 24-gnome batch (DECISIONS.md 2026-10-15: "the genesis gnome
  58//     belongs to nobody, permanently, on purpose"). Its name is not yet settled anywhere in this
  59//     project's documents; genesisGnomeName below is a placeholder, named as one.
  60//
  61// WHAT IS DELIBERATELY OUT OF SCOPE FOR THIS FILE, EACH A SCOPING DECISION MADE BY THIS PASS,
  62// NOT A GAP IN THE BRIEF THAT ASKED FOR IT. Recorded here in the open the same way MINT.md
  63// records its own open questions, so a future reader finds the reasoning in one hop:
  64//
  65//   - NO SetRule/FreezeRules TUNABLE ADMIN SURFACE. v4's ruleBand/SetRule/FreezeRules machinery
  66//     is real and proven, but every care-mechanic number below is a fixed constant at v4's own
  67//     deploy defaults instead. The brief this file was written against lists eleven concrete
  68//     requirements and none of them is "tunable rules"; adding v4's whole admin-ratchet surface
  69//     on top of everything already new here would have roughly doubled this file's size and
  70//     its audit surface for a feature nobody asked for in this pass. A v6 (or a v5.1 companion
  71//     design note) can add it back following v4's own pattern exactly if the owner wants it.
  72//   - NO PLAYER-CHOSEN NAME. v4's name/validNameRune/validNameCharset/hasVisibleNameContent
  73//     machinery (three audit rounds' worth, per v4's own comment) existed to validate a string
  74//     nobody in this brief asked this realm to accept any more -- nothing is chosen by a caller
  75//     at mint time, including a name. A gnome is identified by its tokenID and, for the three
  76//     one-of-ones, by Character(tokenID); the app is free to let an owner caption their own
  77//     gnome off chain. Dropping this also removes an entire validation subsystem's audit surface
  78//     for no loss against the brief.
  79//   - NO OpenBatch ADMIN SURFACE. MINT.md section 6 floats an owner-callable OpenBatch for
  80//     adding future batches' rosters; the task this file was built against names a single
  81//     24-gnome batch (BATCH-ONE.md) and does not ask for a mechanism to open a second one. The
  82//     batch here is sized once, at deploy, by the batchCap/numNamedEditions constants below. A
  83//     garden/v6 is the natural place for OpenBatch if a second batch is ever wanted, the same
  84//     way every other irreversible-shape decision in this project has gotten its own version
  85//     rather than a retrofit.
  86//
  87// A REAL FLAW FOUND AND FIXED WHILE DESIGNING Settle, RECORDED HERE RATHER THAN QUIETLY FIXED
  88// AND FORGOTTEN -- the same discipline MINT.md section 10 models for its own single-transaction
  89// finding. An earlier version of this design picked the three named one-of-ones by drawing
  90// "without replacement" from a shrinking pool: remainingTotal = batchCap - settledCount,
  91// remainingNamed = 3 - namedAssignedCount, and a claimant's pick = seed % remainingTotal. THAT
  92// IS PEEKABLE AND GAMEABLE, even though the seed itself is honestly drand-derived and
  93// unforgeable: because remainingTotal only ever shrinks as OTHER claimants settle, and Settle is
  94// permissionless and callable at any later time, a claimant whose seed is already fixed (the
  95// moment their pinned round publishes) can simply precompute, for every possible remainingTotal
  96// from 1 to batchCap, whether that value would win them a named edition -- and then wait,
  97// costing nothing, until the rest of the batch happens to settle into a state that does. The fix
  98// below removes every dependency on shrinking, observable, time-varying state from the decision:
  99// a claimant's own named-edition pick is `seed % batchCap`, against a modulus that is a
 100// COMPILE-TIME CONSTANT, not a moving count, so there is nothing left to wait for -- the only
 101// residual order-dependence is which of possibly several colliding claimants (bad luck, not
 102// strategy: nobody can change their own pick by choosing when to settle) gets a named edition
 103// that more than one of them rolled, and that is resolved by whichever Settle call lands on
 104// chain first.
 105//
 106// A SECOND AUDIT PASS NAMED THIS PRECISELY: "settling promptly", said above and in an earlier
 107// version of Settle's own comment, OVERSTATES what the residual actually rewards. It is not
 108// "whoever claimed first" or "whoever cared most" -- pendingClaim.claimedAt is never consulted
 109// by the tie-break, and Settle is permissionless, so a bystander (a searcher, a bribed relayer,
 110// a claimant's own bot) can win a collision for whichever side pays for transaction-ordering
 111// priority, regardless of who rolled or claimed first. Considered and rejected: tie-breaking on
 112// claimedAt instead. It sounds like the honest fix, but it is not reachable without a worse
 113// trade-off -- Settle finalizes irrevocably (it mints a real token to a real owner, who may
 114// already have acted on it), so honoring an earlier claimedAt would mean either refusing to
 115// finalize ANY collision candidate until every possible earlier claimant's round has published
 116// and settled (reintroducing exactly the wait-and-see window section 10 and this fix exist to
 117// close, now around a deadline instead of a pool size) or reaching back to UNDO an already-
 118// settled mint (which nothing in this realm, or any grc721-based realm in this project, has ever
 119// claimed to be able to do, and which would be a strictly worse property than the one being
 120// fixed). What actually holds, stated precisely rather than aspirationally: nobody can improve
 121// THEIR OWN odds by choosing when to settle (the property section 10 is about, and the property
 122// this fix actually guarantees); the residual is an ordinary transaction-ordering race over a
 123// LOW-PROBABILITY, LOW-STAKES event (three slots in a batch of 24, decided among whichever
 124// handful of claimants happen to collide), the same shape of race every permissionless action on
 125// every chain is already subject to, not a new hole this design opened. See Settle's own comment
 126// for the arithmetic.
 127//
 128// SETTLE CAN NOW PAY FOR ITS OWN BEACON RELAY, OWNER-APPROVED 2026-10-15, THE DAY AFTER THIS FILE
 129// WAS FIRST WRITTEN. The two-phase roll above depends on beacon/v2 actually having the pinned
 130// round's drand signature by the time Settle runs, and until this change that was somebody
 131// else's problem: a relay has to happen, by a human or a bot, running outside this realm and
 132// outside this project's control, or a claimant who has already spent their one-and-only roll is
 133// stuck mid-mint through no fault of their own -- found live, not hypothetically, while building
 134// this file: the project's own beacon was observed ~2 days stale. The fix the owner asked for
 135// ("can we have the mint transaction pay the beacon though") is `SettleWithSignature` below: the
 136// player's own app fetches the pinned round's signature from drand's public API (the same
 137// `https://api.drand.sh/<chain>/public/<round>` this file's own test fixtures are fetched from)
 138// and passes it straight through; Settle relays it itself, in the same transaction, before
 139// computing the result. One transaction, and the player pays its gas -- TO THE NETWORK, same as
 140// any other transaction's gas, NOT to this project, so it never touches the free-mint promise
 141// (MINT.md section 14: "gas is still a payment, just not to us... the operator takes no stake").
 142// Three things this buys, in the owner's own order of importance:
 143//
 144//   - IT COSTS THE PROJECT NOTHING. The alternative to a player-paid relay is an operator-run one
 145//     -- a cron job or a bot calling beacon.Publish on a schedule, which is real recurring GNOT
 146//     spend with no revenue to offset it (MINT.md section 14 forecloses an unlock fee, batch one
 147//     is free end to end). This change deletes that cost rather than budgeting for it.
 148//   - A PLAYER CAN NEVER BE BLOCKED BY THIS PROJECT'S OWN HOUSEKEEPING. The stuck-mid-mint state
 149//     above is now unreachable for anyone willing to fetch their own round's signature: their own
 150//     Settle call makes the round available, with no dependency on this project, a third-party
 151//     relay, or anyone else's goodwill.
 152//   - THE BEACON GETS FRESHER AS A SIDE EFFECT, FOR EVERYONE ELSE USING IT. beacon/v2 is a shared,
 153//     permissionless, on-demand record (see its own package doc) -- a round this realm's own
 154//     claimants cause to be published is published for every other consumer of that beacon too,
 155//     not just for this realm.
 156//
 157// THREE THINGS WORKED OUT BY QUERYING THE LIVE CHAIN, NOT ASSUMED, because this is new
 158// cross-realm write surface and this project's own history (the SPLASH beacon migration,
 159// MEMORY.md) is built from exactly the kind of interrealm detail that gets guessed wrong:
 160//
 161//  1. WHAT beacon.Publish DOES ON AN ALREADY-PUBLISHED ROUND. Fetched byte-for-byte from the live
 162//     deployed realm itself (gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/beacon/v2) via
 163//     the public onyx-1 RPC's `vm/qfile` query, 2026-10-15, and diffed identical against this
 164//     project's own vendored copy at realms/_vendor/beacon/v2/beacon.gno -- it PANICS, it does not
 165//     no-op: `func Publish(cur realm, round int64, sigHex string) { if Published(round) { panic
 166//     ("beacon: round already published") } ... }`. SettleWithSignature therefore checks
 167//     `beacon.Published` itself BEFORE ever calling Publish, exactly once, and never calls Publish
 168//     at all if the round is already there -- calling it unconditionally would make this function
 169//     fail every time a relay (another claimant's own SettleWithSignature, or anyone else) beat it
 170//     to the punch, which given Settle's own permissionless, callable-by-anyone design is an
 171//     ordinary and expected race, not an edge case.
 172//  2. THAT Publish IS REACHABLE CROSS-REALM AS `beacon.Publish(cross(cur), round, sigHex)`, and
 173//     that it carries no caller-identity check at all for this function to trip over. Confirmed
 174//     two ways: the live source pulled in point 1 shows Publish's body never reads `cur` or calls
 175//     `cur.Previous()` -- it is permissionless by whoever relays, not merely by whichever address
 176//     -- and this exact call shape is already proven working, not merely plausible, by this
 177//     file's OWN test harness: garden_test.gno's `publishAs` helper has called
 178//     `beacon.Publish(cross(cur), round, sigHex)` from this package since the day this file was
 179//     written, and every test that calls it passes. SettleWithSignature's own call is the
 180//     identical shape with `cur` being Settle's own realm parameter instead of a test helper's.
 181//     (`cur.Previous()` inside beacon.Publish would resolve to THIS realm, garden/v5, when called
 182//     this way rather than to the human wallet that called Settle -- a real interrealm fact worth
 183//     recording even though Publish does not currently branch on it: if beacon/v2 ever grows a
 184//     caller-identity check, re-read this paragraph before assuming Settle's relay still works.)
 185//  3. GAS. Measured on this toolchain with r/splash/beacon/zz_gas_test.gno's own method
 186//     (`gno test -run TestZZGas -print-runtime-metrics=true -v`, net of this package's own fixed
 187//     per-test-call overhead): a bare Settle today costs ~6.2M gas; beacon.Publish alone costs
 188//     ~18.5M gas on THIS realm's measurement (the same order of magnitude as beacon/v2's own
 189//     documented 16,970,375, re-measured rather than quoted because toolchain and realm storage
 190//     both shift it). SettleWithSignature's worst case -- the round not yet published at all --
 191//     is therefore on the order of ~25M gas total, call it 1% of the 3e9 block gas limit and a
 192//     few hundredths of a GNOT at this chain's gas price. Reasonable: it is roughly four times a
 193//     bare Settle, not an order of magnitude more, it still leaves room for well over a hundred
 194//     such settlements in a single block, and it replaces a cost this project would otherwise have
 195//     paid itself, recurringly, forever, to keep the beacon fresh by some other means.
 196//
 197// THE SHAPE: TWO ENTRY POINTS, ONE SHARED CORE, NOT ONE FUNCTION WITH AN OPTIONAL SIGNATURE.
 198// `Settle(cur realm, claimant address)` is UNCHANGED in behaviour and UNCHANGED in signature --
 199// every existing test and every existing caller that already knows (or does not care) whether the
 200// round is published keeps working exactly as before, at exactly its old gas cost, with no
 201// signature to fetch or supply. `SettleWithSignature(cur realm, claimant address, sigHex string)`
 202// is new, and is for a caller that HAS a signature and wants its transaction to publish the round
 203// if nobody else already has. A Gno realm's exported functions take a fixed argument list -- there
 204// is no overloading, and an optional/variadic last argument would still force every caller,
 205// including every test and the app's own simpler "it's already published" path, to pass an empty
 206// string they have no use for, for a case (publishing) that is the EXCEPTION, not the rule, since
 207// most settlements land after some earlier claimant or relay has already published the shared
 208// round. Two named entry points make the exception visible in the function name instead of buried
 209// in an argument, and cost nothing extra: both share one unexported `settleCore`, so there is
 210// exactly one copy of the draw-and-mint logic, not two drifting in parallel. And it is SAFE for
 211// either one to be called in either actual on-chain state: Settle still refuses cleanly, with its
 212// original message, if the round genuinely is not published yet; SettleWithSignature still
 213// succeeds, with its supplied signature simply going unused, if the round turns out to already be
 214// published by the time it runs (point 1's race) -- neither caller has to know in advance which
 215// state they are in, they only have to know whether they personally HAVE a signature to offer.
 216package garden
 217
 218import (
 219	"strconv"
 220	"strings"
 221	"time"
 222
 223	"gno.land/p/nt/avl/v0"
 224	"gno.land/p/nt/grc721/v0"
 225	"gno.land/p/nt/seqid/v0"
 226
 227	"chain"
 228	"chain/banker"
 229	"chain/runtime/unsafe"
 230
 231	beacon "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/beacon/v2"
 232)
 233
 234// ============================================================================
 235// THE THREE SALE CONSTANTS. CARRIED OVER, UNCHANGED, FROM garden/v4's 2026-10-11 DECISION, AS
 236// PLACEHOLDERS FOR THIS NEW REALM. Nobody has asked the owner to re-confirm these specifically
 237// for v5 -- re-decide or re-confirm them before ever deploying this file; see gnomod.toml's own
 238// checklist item 0. init() below still gates them exactly as v4's assertFeeConfig does, so an
 239// edit to a value that cannot work fails at deploy rather than succeeding silently forever.
 240// ============================================================================
 241const (
 242	saleFeeRecipient  = "g1ylpe9juk8cafgf9rt09wzuz85ug2lyfk5metyv" // gnotchi-opponent, v4's choice
 243	saleFeeRateBps    = int64(500)                                 // 5% of the declared price
 244	saleFeeFloorUgnot = int64(1_000_000)                           // 1 GNOT flat minimum per sale
 245)
 246
 247// ============================================================================
 248// Constants that are NOT tunable, and never will be: see the package doc's "out of scope"
 249// section for why v5 has no SetRule surface at all.
 250// ============================================================================
 251
 252const (
 253	collectionName   = "Gnotchi gnomes"
 254	collectionSymbol = "GNOME"
 255
 256	// adminAddress is the only address AdminAllow/AdminDisallow will accept. Compiled in, with
 257	// no transfer function, for the same reason v4 has none: an admin-transfer path is a second
 258	// authority path, and a lost admin key merely freezes the allowlist, which is recoverable by
 259	// redeploying a v6 -- not a disaster.
 260	//
 261	// THIS IS THE ONYX TESTNET DEPLOY KEY `gnotchi-deploy`, which is also the namespace this
 262	// realm deploys under (see gnomod.toml) and the only key on this host with enough GNOT to
 263	// pay for the admin calls -- AdminAllow and Settle both cost fees, and v4's old key
 264	// (g1r4aw43..., the retired splash-deploy) is down to dust, so compiling that address in
 265	// here would have produced an admin that could not afford to act. Correct for onyx, wrong
 266	// for anywhere real. Change it before deploying anywhere that matters; see gnomod.toml.
 267	adminAddress = "g1nq99m3h9ed7vqve6ajpr3qwy2ls252m8jnundm"
 268
 269	// numEyes / numNoses / the hat and coat catalogues: THE SAME FOUR-DIMENSIONAL FACE SPACE
 270	// garden/v4 uses (SPRITES.md's Phase 1: 10 eyes x 8 noses x 4 hats x 4 coats = 1,280 looks).
 271	// Nothing here is a new catalogue; eyesWeights/noseWeights below are a weighted DRAW over
 272	// the same ids v4 already validates, per ECONOMY.md section 3 and MINT.md section 13's
 273	// counted-rarity decision ("no tiers... rarity becomes population frequency counted on
 274	// chain"). hat and coat are drawn UNIFORMLY (25% each): ECONOMY.md section 3 only specifies
 275	// weights for the "permanent pair" (eyes, nose), and BATCH-ONE.md section 4a says so
 276	// explicitly -- "assigning the section 3 percentages to hat and coat as well... is real work
 277	// this document does not do... flagged here as the next thing to specify". A uniform draw is
 278	// the honest placeholder until that work happens, not a guess dressed up as one.
 279	numEyes  = int64(10)
 280	numNoses = int64(8)
 281
 282	// hourSeconds/daySeconds: unchanged from v4.
 283	hourSeconds = int64(3600)
 284	daySeconds  = int64(86400)
 285
 286	// ---- care-mechanic constants, fixed at v4's own deploy DEFAULTS (v4's ruleBands table).
 287	// See the package doc: v5 deliberately carries no tunable-rules admin surface.
 288	numPlots        = 3        // acorns.plots
 289	plantCost       = int64(1) // acorns.plantCost
 290	feedCost        = int64(1) // acorns.feedCost
 291	acornsStart     = int64(10)
 292	meterMax        = int64(10)
 293	patCheer        = int64(3)
 294	hungryAtOrBelow = int64(2)
 295
 296	growHours           = int64(24)
 297	patCooldownHours    = int64(12)
 298	tinkerCost          = int64(5)
 299	levelBase           = int64(50)
 300	levelStep           = int64(25)
 301	sulkyPenaltyPct     = int64(10)
 302	harvestPerPlot      = int64(3)
 303	feedBelly           = int64(4)
 304	bellyDropEveryHours = int64(3)
 305	cheerDropEveryHours = int64(6)
 306	dailyCareCap        = int64(60)
 307
 308	// acornReserve: v4's fix for the acorn softlock (AUDIT-DEPLOYED.md G9), carried over
 309	// unchanged. See v4's own constant for the full argument; the short version is that a
 310	// SaveDay batch that spends acorns may never end with fewer than this and nothing planted.
 311	acornReserve = plantCost + feedCost
 312
 313	happyBonusPct    = int64(10)
 314	happyWindowHours = int64(2)
 315	sulkyAfterHours  = int64(72)
 316	maxLevel         = int64(20)
 317	typeAtLevel      = int64(5)
 318
 319	xpPat            = int64(5)
 320	xpFeed           = int64(5)
 321	xpHarvestPerPlot = int64(10)
 322	xpTinkerTask     = int64(15)
 323
 324	maxActionsPerCall = 32
 325	maxBackfillHours  = int64(48)
 326	caredWindowDays   = uint(64)
 327
 328	maxGnomesPageSize   = 100
 329	maxListingsPageSize = 100
 330
 331	// ---- the sale. Shape constants, unchanged from v4.
 332	ugnotDenom     = "ugnot"
 333	maxPriceUgnot  = int64(1_000_000_000_000_000)
 334	maxFeeRateBps  = int64(10_000)
 335	maxListingDays = int64(30)
 336
 337	moodHungry  = "hungry"
 338	moodSulky   = "sulky"
 339	moodHappy   = "happy"
 340	moodContent = "content"
 341
 342	actionPat     = "pat"
 343	actionFeed    = "feed"
 344	actionPlant   = "plant"
 345	actionHarvest = "harvest"
 346	actionTinker  = "tinker"
 347
 348	typeGardener = "gardener"
 349	typeExplorer = "explorer"
 350	typeTinkerer = "tinkerer"
 351
 352	// ============================================================================
 353	// THE MINT: allowlist, one roll per wallet, the batch, the lock, the drand pin. Every
 354	// number below is a single named constant, each trivially changeable, per the brief's own
 355	// instruction for the lock specifically and applied here to the pin offset too.
 356	// ============================================================================
 357
 358	// batchCap: BATCH-ONE.md's own roster -- 24 gnomes, total, forever, this batch. 21 ordinary
 359	// plus three named one-of-ones (numNamedEditions below). Closed the instant mintedCount
 360	// reaches this; nothing in this file can ever raise it (no OpenBatch -- see the package
 361	// doc's "out of scope" section).
 362	batchCap = int64(24)
 363
 364	// numNamedEditions: Hob, Thistle and Wick (namedEditionOrder below), cap ONE EACH. "Cap 1" is
 365	// not a different mechanism from any other cap, MINT.md section 3's own point -- it is the
 366	// same characterOwner.Has check any other population cap would use, just at the number one.
 367	numNamedEditions = 3
 368
 369	// lockCaredDays: MINT.md section 12/13's RECOMMENDATION, explicitly not yet confirmed by the
 370	// owner ("Option A... this is a recommendation, not a decision"). The three named one-of-ones
 371	// cannot be listed until they have this many cared days; every ordinary gnome trades from
 372	// day one. One named constant, nothing else in this file assumes 30 specifically.
 373	lockCaredDays = int64(30)
 374
 375	// pinOffsetSeconds / pinOffsetRounds: how far into drand's future Claim pins a round, sized
 376	// AS SECONDS, from drand evmnet's own period (beacon.PeriodSeconds, re-exported from this
 377	// project's own bls254 package, not restated here), per MINT.md section 10.5's explicit
 378	// instruction -- never as a round count checked only against a block count, which is the
 379	// exact dimensional trap this project's own history (the SPLASH raffle shipping with a 100x
 380	// wrong round/block ratio, MEMORY.md) says cost real time once already. 60 seconds is twenty
 381	// drand rounds at the 3-second period CurrentRound() at Claim time is comfortably able to
 382	// pin as "not yet signed by anyone, including drand itself" -- see Claim's own comment for
 383	// why the margin matters even though CurrentRound() is pure arithmetic over block time, not
 384	// a relay-dependent estimate.
 385	pinOffsetSeconds = int64(60)
 386)
 387
 388// pinOffsetRounds is a const expression over an imported const (beacon.PeriodSeconds), which Gno
 389// permits across package boundaries exactly as it would within one; TestPinOffsetIsTwentyRounds
 390// pins the arithmetic rather than trusting the comment above.
 391const pinOffsetRounds = pinOffsetSeconds / beacon.PeriodSeconds
 392
 393// kindEnd-style ratchet machinery: deliberately absent. See the package doc.
 394
 395// hatOptions / coatOptions: v4's validHats/validCoats catalogue, as an ORDERED array rather than
 396// a set, because Settle needs to draw a POSITION, not merely validate one. Order is frozen the
 397// moment this realm deploys, exactly as v4's own numEyes/numNoses ordering is frozen -- a part
 398// may be redrawn; it may never be reordered, removed or renumbered (v4's own comment on this).
 399var hatOptions = [4]string{"red", "purple", "green", "navy"}
 400var coatOptions = [4]string{"blue", "teal", "orange", "brown"}
 401
 402// eyesWeights: ECONOMY.md section 3, verbatim -- "eyes across 4 colours at 15%, 3 at 9%, 2 at
 403// 5%, 1 at 3%" -- 4+3+2+1 = 10 entries = numEyes, and 4*15+3*9+2*5+1*3 = 100.
 404// TestWeightTablesSumToOneHundred pins the sum rather than trusting the arithmetic in this
 405// comment; drawWeighted's own fallback additionally makes a drift here fail safe, not silently.
 406var eyesWeights = [10]int64{15, 15, 15, 15, 9, 9, 9, 5, 5, 3}
 407
 408// noseWeights: ECONOMY.md section 3 says only "nose similar across 8 shapes" and gives no exact
 409// percentages -- BATCH-ONE.md section 4a names this directly as real work nobody has done yet.
 410// This is a SHAPE-FAITHFUL INTERPRETATION, not a transcription: four tiers of two shapes each,
 411// 20/20/15/15/10/10/5/5, the same big-gap-then-smaller-gaps shape eyesWeights has, scaled to 8
 412// entries and summing to 100. Flagged here, not quietly presented as sourced, so the owner or a
 413// future author can replace it with real numbers the moment ECONOMY.md section 3 gets them.
 414var noseWeights = [8]int64{20, 20, 15, 15, 10, 10, 5, 5}
 415
 416// namedEditionOrder: BATCH-ONE.md section 7's three names, in the fixed order Settle's
 417// namedPick indexes into. The order carries no meaning -- it is not a ranking -- it only has to
 418// be fixed, which it is, forever, the moment this realm deploys.
 419var namedEditionOrder = [numNamedEditions]string{"hob", "thistle", "wick"}
 420
 421// genesisGnomeName: DECISIONS.md 2026-10-15 settles the MECHANISM (minted to this realm's own
 422// address, non-transferable, never listable, separate from the batch) but never settles a NAME
 423// anywhere in this project's documents. Placeholder, named as one; change it freely before
 424// deploying, it is read by nothing else in this file.
 425const genesisGnomeName = "Genesis"
 426
 427// ============================================================================
 428// Storage
 429// ============================================================================
 430
 431// plot: unchanged from v4. plantedAt == 0 is the empty state; readyAt is stored, not computed,
 432// so a future change to growHours (there is none possible in this file, but the shape is kept
 433// for the day a v6 adds tuning back) could never reach backwards into an already-planted plot.
 434type plot struct {
 435	plantedAt int64
 436	readyAt   int64
 437}
 438
 439// gnome is the whole persisted pet. EVERY FIELD HERE HAS AN EXPORTED READER -- v4's own rule,
 440// carried over verbatim: a realm cannot grow a second chance at this after deploy.
 441//
 442// NOT HERE, DELIBERATELY: a name (see the package doc's "out of scope" section) and a hat/coat
 443// the OWNER chose (nothing is chosen any more; hat/coat are ROLLED exactly as eyes/nose are,
 444// and are exactly as permanent as eyes/nose always were in v4 -- there has never been a function
 445// anywhere in this family of realms that changes a gnome's hat or coat after mint).
 446//
 447// character is "" for every ordinary gnome and one of namedEditionOrder's three strings for a
 448// named one-of-one. eyes/nose/hat/coat are the sentinel -1/-1/""/"" for a named edition: its
 449// look is NOT a point in the ordinary 1,280-look space, it is BATCH-ONE.md section 6's own new
 450// art (Hob's crown and gold coat, Thistle's helmet and axe, Wick's lantern), which this contract
 451// does not need to know the pixels of -- only that Character(tokenID) names it, so the app can
 452// render the right thing. A named edition's parts are therefore never counted in
 453// eyesCounts/noseCounts/hatCounts/coatCounts: it is already as rare as a thing can be, at cap
 454// one, counted instead through characterOwner.
 455type gnome struct {
 456	hat       string
 457	coat      string
 458	eyes      int64
 459	nose      int64
 460	character string
 461
 462	level     int64
 463	gnomeType string
 464	adoptedAt int64 // unix seconds; the moment Settle minted this gnome, or init() for genesis
 465
 466	belly   int64
 467	bellyAt int64
 468	cheer   int64
 469	cheerAt int64
 470
 471	lastPatAt        int64
 472	lastHappyEventAt int64
 473
 474	totalXp int64
 475
 476	xpGarden     int64
 477	xpTinker     int64
 478	harvestCount int64
 479
 480	dailyCareXp  int64
 481	dailyCareDay int64
 482
 483	plots [numPlots]plot
 484
 485	lastTinkerAt int64
 486
 487	// Cared days. v4's own shape, unchanged: the chain stores FACTS (which UTC days were
 488	// cared for), never the STREAK RULE, which stays in app/js/rules.js's streakFrom. See
 489	// v4's gnome struct comment for the full argument; it applies here without edit.
 490	caredDayCount int64
 491	lastCaredDay  int64
 492	caredMask     uint64
 493
 494	lastActionAt int64
 495
 496	// lastWokenAt: unix seconds, the moment THIS token last became somebody's active gnome --
 497	// set at mint (settleCore), at purchase (Buy), and at every explicit Wake, always to "now" at
 498	// that moment, never anything earlier. SaveDay's backfill floor is max(lastActionAt,
 499	// lastWokenAt, now-48h): a token cannot be credited for any period before it was actually the
 500	// active one, closing the gap ATTACK-REVIEW.md goal 7 found -- v4 never needed this field
 501	// because v4 enforced one gnome per address, so there was no second token to backfill against
 502	// while this one slept. See SaveDay's own comment for the full argument.
 503	lastWokenAt int64
 504
 505	acquiredAt int64
 506	saleCount  int64
 507}
 508
 509// listing: unchanged in shape from v4, now simply one of possibly SEVERAL a single owner may
 510// have standing at once (mint one, hold many) -- listings is still keyed by tokenID, which was
 511// always the right key for this and needed no change.
 512type listing struct {
 513	seller     address
 514	priceUgnot int64
 515	buyer      address
 516	listedAt   int64
 517	expiresAt  int64
 518}
 519
 520// feeConfig: unchanged from v4.
 521type feeConfig struct {
 522	recipient  address
 523	rateBps    int64
 524	floorUgnot int64
 525}
 526
 527// pendingClaim is the record Claim writes and Settle consumes: the permanent, irrevocable fact
 528// that this address spent its one roll, and the future drand round it is waiting on.
 529type pendingClaim struct {
 530	round     int64
 531	claimedAt int64
 532}
 533
 534var (
 535	token  *grc721.Token
 536	ledger *grc721.PrivateLedger
 537
 538	nextID seqid.ID
 539
 540	gnomes = avl.NewTree() // tokenID -> *gnome
 541
 542	// ownerTokens: owner address -> *[]string, every tokenID that address currently holds.
 543	// MINT.md section 2a's "hold many": v4 never needed this because v4 never let an address
 544	// hold more than one. See addOwnerToken/removeOwnerToken.
 545	ownerTokens = avl.NewTree()
 546
 547	// activeToken: owner address -> tokenID string, the ONE gnome that is awake for that
 548	// address right now. Absent means "nothing awake" -- reachable only for an address that has
 549	// never settled a claim or bought a gnome, or that has sold its only awake gnome and not yet
 550	// woken another (Buy deliberately does not auto-promote a replacement; see Buy's comment).
 551	activeToken = avl.NewTree()
 552
 553	// lastWakeDay: owner address -> int64 UTC day number, the once-per-day gate on Wake.
 554	lastWakeDay = avl.NewTree()
 555
 556	// lastCaredDayForOwner: owner address -> int64 UTC day number, the most recent calendar day
 557	// on which ANY ONE of this owner's tokens has already banked a cared day. The package doc is
 558	// explicit and unconditional: "exactly one is ever AWAKE... and only the awake one can ever
 559	// gain... a cared day" (see this file's own doc comment). lastWokenAt (see the gnome struct)
 560	// closes the BACKFILL half of ATTACK-REVIEW.md goal 7 -- a token cannot backfill a day it
 561	// spent asleep. This tree closes the LIVE half: without it, an owner could Wake(B) partway
 562	// through a UTC day already spent genuinely caring for A, then genuinely care for B too,
 563	// using only real-time, non-backfilled timestamps, and bank the SAME calendar day on BOTH --
 564	// no backfill panic ever fires, because neither action is backfilled, each is simply real
 565	// care given to whichever gnome was actually awake at the time. That still doubles the one
 566	// property this bookkeeping exists to measure (lockCaredDays' 30-day resale lock, and the
 567	// public cared-days streak), for one wallet, once per day, for as many tokens as the owner is
 568	// willing to switch through -- the exact harm goal 7 names, reached by a second, undiscovered
 569	// mechanism. recordCaredDay only ever runs, and this tree only ever advances, when the day
 570	// being recorded is NEWLY banked for this OWNER, not merely newly banked for this TOKEN.
 571	lastCaredDayForOwner = avl.NewTree()
 572
 573	balances = avl.NewTree() // owner address -> int64 acorns, per PLAYER, exactly as v4
 574
 575	listings = avl.NewTree() // tokenID -> *listing
 576
 577	// allowlist: owner address -> true. Admin-managed (AdminAllow/AdminDisallow), gates Claim
 578	// only -- MINT.md section 11: "gate issuance, never the secondary market". Buying and
 579	// selling need no entry here at all.
 580	allowlist = avl.NewTree()
 581
 582	// hasRolled: owner address -> true, PERMANENT, never removed by anything in this file. "One
 583	// roll per wallet, forever" (MINT.md section 1a/2.3), independent of whether the resulting
 584	// gnome is still held, independent of whether the address is later removed from the
 585	// allowlist. Disallowing an address after it has rolled does not and cannot un-roll it.
 586	hasRolled = avl.NewTree()
 587
 588	// pendingClaims: owner address -> *pendingClaim. Written by Claim, removed by Settle. At
 589	// most one per address, because hasRolled already refuses a second Claim from the same one.
 590	pendingClaims = avl.NewTree()
 591
 592	// eyesCounts / noseCounts / hatCounts / coatCounts: counted rarity (MINT.md section 13).
 593	// Keyed by the part's own id/name, value is a lifetime population count, incremented only by
 594	// an ORDINARY Settle (never by a named one-of-one, which is counted separately below, and
 595	// never decremented: there is no burn, and a sale moves a part without minting one).
 596	eyesCounts = avl.NewTree()
 597	noseCounts = avl.NewTree()
 598	hatCounts  = avl.NewTree()
 599	coatCounts = avl.NewTree()
 600
 601	// characterOwner: one of namedEditionOrder's three names -> tokenID, present only once that
 602	// edition has actually been minted. Has(name) is both "has this cap-one edition been taken"
 603	// (Settle's own fallback check) and "does it exist yet" (CharacterOwner's reader) -- the same
 604	// single tree answers both questions, which is why there is no separate counter for it.
 605	characterOwner = avl.NewTree()
 606
 607	mintedCount  int64 // reservations made by Claim, gates batchCap; never decremented
 608	settledCount int64 // completions made by Settle; informational only, see BatchStatus
 609
 610	genesisTokenID string // set exactly once, in init()
 611
 612	rulesFrozen = true // always true: there are no rules to freeze or unfreeze in v5. See AdminState.
 613
 614	fee feeConfig
 615
 616	transferGate string
 617)
 618
 619func init(cur realm) {
 620	if !address(adminAddress).IsValid() {
 621		panic("garden: adminAddress is not a valid address; fix the constant before deploying")
 622	}
 623
 624	cfg := feeConfig{
 625		recipient:  address(saleFeeRecipient),
 626		rateBps:    saleFeeRateBps,
 627		floorUgnot: saleFeeFloorUgnot,
 628	}
 629	assertFeeConfig(cfg, cur.Address())
 630	fee = cfg
 631
 632	token, ledger = grc721.NewToken(collectionName, collectionSymbol, 0, cur)
 633	ledger.RegisterExtension(&saleGateExtension{})
 634
 635	// THE GENESIS GNOME. Minted here, directly, to this realm's own address -- never through
 636	// Claim, never counted against batchCap, never allowlist-gated, because it is not a roll and
 637	// not part of the batch (DECISIONS.md 2026-10-15). No wallet holds this realm's private key,
 638	// so no transaction can ever be signed as its owner: List, Buy and Wake all additionally
 639	// refuse genesisTokenID by name as a second, redundant lock, and saleGateExtension's
 640	// OnTransfer refuses it unconditionally as a third -- the same "two locks on a door that
 641	// already has one" discipline v4's own sale gate uses, because the claim "belongs to
 642	// nobody" is only as strong as the weakest reachable path around it.
 643	id := nextID.Next()
 644	genesisTokenID = id.String()
 645	me := cur.Address()
 646	if err := ledger.Mint(me, grc721.TokenID(genesisTokenID)); err != nil {
 647		panic(err)
 648	}
 649	now := time.Now().Unix()
 650	gnomes.Set(genesisTokenID, &gnome{
 651		character:    "genesis",
 652		eyes:         -1,
 653		nose:         -1,
 654		level:        1,
 655		adoptedAt:    now,
 656		belly:        meterMax,
 657		bellyAt:      now,
 658		cheer:        meterMax,
 659		cheerAt:      now,
 660		lastActionAt: now,
 661		acquiredAt:   now,
 662	})
 663	addOwnerToken(me, genesisTokenID)
 664
 665	// Registered under its own name, "genesis", in the SAME tree the three named one-of-ones
 666	// use -- not because it competes with their cap (BatchStatus's namedMinted count iterates
 667	// namedEditionOrder only, which "genesis" is never a member of, so this cannot affect the
 668	// batch), but so CharacterOwner("genesis") resolves instead of silently reporting not-found
 669	// for the one named character that is neither ordinary nor one of the three. Second-audit-
 670	// pass finding (G7): fixed here, not left as a reader inconsistency.
 671	characterOwner.Set("genesis", genesisTokenID)
 672
 673	chain.Emit("Genesis", "tokenId", genesisTokenID, "name", genesisGnomeName)
 674}
 675
 676// assertFeeConfig: unchanged from v4's own gate.
 677func assertFeeConfig(cfg feeConfig, realmAddr address) {
 678	if !cfg.recipient.IsValid() {
 679		panic("garden: DEPLOY GATE: saleFeeRecipient is not a valid address; fix the constant at the top of garden.gno before deploying")
 680	}
 681	if cfg.recipient == realmAddr {
 682		panic("garden: DEPLOY GATE: saleFeeRecipient is this realm's own address, and coins sent there are burned because there is no withdrawal path")
 683	}
 684	if cfg.recipient == address(adminAddress) {
 685		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")
 686	}
 687	if cfg.rateBps < 1 || cfg.rateBps > maxFeeRateBps {
 688		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")
 689	}
 690	if cfg.floorUgnot < 1 || cfg.floorUgnot > maxPriceUgnot {
 691		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")
 692	}
 693}
 694
 695// saleGateExtension: v4's shape, plus the genesis refusal. Panics on every transfer except the
 696// one Buy is performing right now, and ALWAYS panics for genesisTokenID regardless of
 697// transferGate -- there is no path, including a future bug in Buy, by which the genesis gnome
 698// should ever be transferable, so this checks it first and unconditionally.
 699type saleGateExtension struct{}
 700
 701func (saleGateExtension) ExtensionKind() string { return "sale-gate" }
 702
 703func (saleGateExtension) OnMint(to address, tid grc721.TokenID) {}
 704
 705func (saleGateExtension) OnTransfer(from, to address, tid grc721.TokenID) {
 706	if tid.String() == genesisTokenID {
 707		panic("garden: the genesis gnome belongs to nobody and can never change hands")
 708	}
 709	if transferGate == "" || transferGate != tid.String() {
 710		panic("garden: a gnome can only change hands through Buy; every other transfer is permanently disabled")
 711	}
 712}
 713
 714func (saleGateExtension) OnBurn(tid grc721.TokenID) {
 715	panic("garden: gnomes cannot be burned")
 716}
 717
 718// ============================================================================
 719// Admin: the allowlist. The only admin-reachable state in this whole realm -- there is no
 720// SetRule, no FreezeRules, no fee steward, no treasury reach, nothing else at all.
 721// ============================================================================
 722
 723// AdminAllow adds addr to the allowlist, so it may call Claim. Adding an address a second time
 724// is a harmless no-op. Adding an address that has already rolled changes nothing -- hasRolled is
 725// checked independently and is permanent regardless of allowlist state.
 726func AdminAllow(cur realm, addr address) {
 727	if !unsafe.OriginSend().IsZero() {
 728		panic("garden: admin calls accept no coins")
 729	}
 730	caller := cur.Previous().Address()
 731	if caller != address(adminAddress) {
 732		panic("garden: only the admin may manage the allowlist")
 733	}
 734	if !addr.IsValid() {
 735		panic("garden: not a valid address")
 736	}
 737	allowlist.Set(addr.String(), true)
 738	chain.Emit("AdminAllow", "admin", caller.String(), "address", addr.String())
 739}
 740
 741// AdminDisallow removes addr from the allowlist. It does NOT, and cannot, undo a roll already
 742// spent: hasRolled is a separate, permanent record, and this function never touches it. Removing
 743// an address that was never allowed, or that has already claimed, is a harmless no-op.
 744func AdminDisallow(cur realm, addr address) {
 745	if !unsafe.OriginSend().IsZero() {
 746		panic("garden: admin calls accept no coins")
 747	}
 748	caller := cur.Previous().Address()
 749	if caller != address(adminAddress) {
 750		panic("garden: only the admin may manage the allowlist")
 751	}
 752	allowlist.Remove(addr.String())
 753	chain.Emit("AdminDisallow", "admin", caller.String(), "address", addr.String())
 754}
 755
 756// ============================================================================
 757// Claim: the one roll. Phase one of two. MINT.md section 10.5 step 1.
 758// ============================================================================
 759
 760// Claim spends the caller's one-and-only roll on this realm and pins a drand round that does
 761// not exist yet. It mints NOTHING -- Settle does that, once the pinned round is actually
 762// published -- because the whole point of this split is that the result must not be computable,
 763// by anyone, including this transaction's own caller, at the moment the roll becomes irrevocable
 764// (MINT.md section 10.2: gno.land has no on-chain entropy at all, so anything decidable inside
 765// one transaction is decidable by the caller before broadcasting it).
 766//
 767// WALLET-ONLY, the same reasoning as v4's Adopt: a gnome minted to a realm's address could never
 768// be recovered, so a batching, sponsor or multicall realm must never be able to spend a roll on
 769// anyone's behalf. The `gnokey maketx run` shape is refused by the identical gate, for the
 770// identical reason (IsUserCall is false for both).
 771func Claim(cur realm) {
 772	if !cur.Previous().IsUserCall() {
 773		panic("garden: claim must be called directly by a wallet, not through another realm; the one roll this spends could never be returned to an address that could not hold the resulting gnome")
 774	}
 775	if !unsafe.OriginSend().IsZero() {
 776		panic("garden: claim accepts no coins; the mint is free, exactly as garden/v4's adopt already refuses coins, and nothing here can return them")
 777	}
 778
 779	caller := cur.Previous().Address()
 780	if !caller.IsValid() {
 781		panic("garden: claim requires a valid caller address")
 782	}
 783
 784	if !allowlist.Has(caller.String()) {
 785		panic("garden: this address is not on the allowlist; ask the owner to add it before claiming")
 786	}
 787
 788	if hasRolled.Has(caller.String()) {
 789		panic("garden: this address has already rolled on this realm; one roll per wallet, forever, whether or not the resulting gnome is still held")
 790	}
 791
 792	if mintedCount >= batchCap {
 793		panic("garden: batch one is closed; all " + strconv.FormatInt(batchCap, 10) + " editions have already been claimed")
 794	}
 795
 796	// CurrentRound() is pure arithmetic over THIS call's own block time (RoundAt(time.Now())),
 797	// not an extrapolation from however recently somebody last relayed -- see MINT.md section
 798	// 10.4's own live measurement of EstimatedLatestRound() erroring under a stale relay while
 799	// CurrentRound() kept working regardless. Pinning pinOffsetRounds past it is therefore safe
 800	// against relay staleness; the margin exists only for ordinary block-time/wall-clock skew.
 801	round := beacon.CurrentRound() + pinOffsetRounds
 802
 803	// ---- commit. Nothing below this line can fail. ----
 804	hasRolled.Set(caller.String(), true)
 805	mintedCount++
 806	pendingClaims.Set(caller.String(), &pendingClaim{round: round, claimedAt: time.Now().Unix()})
 807
 808	chain.Emit("Claim", "claimant", caller.String(), "round", strconv.FormatInt(round, 10))
 809}
 810
 811// ============================================================================
 812// Settle: permissionless. Phase two of two. MINT.md section 10.5 step 2. Two entry points, one
 813// shared core -- see the package doc's "SETTLE CAN NOW PAY FOR ITS OWN BEACON RELAY" section for
 814// the full account of why, what it costs, and the two live-chain facts (beacon.Publish panics
 815// rather than no-ops on an already-published round; beacon.Publish is reachable cross-realm as
 816// beacon.Publish(cross(cur), round, sigHex), already proven by this package's own test harness)
 817// that shape of both functions below depends on.
 818// ============================================================================
 819
 820// pendingClaimFor looks up claimant's pending roll, shared by both Settle and
 821// SettleWithSignature so the "no pending claim" message and lookup logic exist exactly once.
 822func pendingClaimFor(claimant address) *pendingClaim {
 823	pcv := pendingClaims.Get(claimant.String())
 824	pc, ok := pcv.(*pendingClaim)
 825	if !ok || pc == nil {
 826		panic("garden: no pending claim for this address; it must Claim first, or this claim has already been settled")
 827	}
 828	return pc
 829}
 830
 831// Settle computes and permanently mints the result of claimant's pending roll, once the round
 832// Claim pinned has actually been published to the beacon by SOMEBODY -- another claimant's own
 833// SettleWithSignature, a relay, anyone. It does not publish anything itself; call
 834// SettleWithSignature instead if the round is not published yet and the caller has its
 835// signature to offer. ANYONE may call this -- the claimant themself, the project's own relayer, a
 836// stranger -- because by the time it can succeed at all, nothing is left to decide: the drand
 837// signature for the pinned round either exists publicly or it does not, and the parts this mints
 838// are a pure function of (that signature, the claimant's own address), fixed the instant the
 839// round is published, not the instant Settle happens to be called. Simulating this call before
 840// calling it for real previews a fact, not a choice -- exactly the property MINT.md section 10.5
 841// names as the whole fix: "reading the result afterward is a free query, same as any other realm
 842// read."
 843//
 844// Refuses coins for the same reason every other non-paying function here does: there is no
 845// banker to return them, and this is a free, permissionless computation, never a payment.
 846func Settle(cur realm, claimant address) {
 847	if !unsafe.OriginSend().IsZero() {
 848		panic("garden: settle accepts no coins; it is a free, permissionless computation, not a payment")
 849	}
 850	if !claimant.IsValid() {
 851		panic("garden: settle requires a valid claimant address")
 852	}
 853
 854	pc := pendingClaimFor(claimant)
 855
 856	if !beacon.Published(pc.round) {
 857		panic("garden: drand round " + strconv.FormatInt(pc.round, 10) + " has not been relayed to the beacon yet; call Settle again once it has, or call SettleWithSignature with the round's drand signature (fetched from drand's own public API) to relay it yourself in this same transaction -- relaying is permissionless, and so is settling")
 858	}
 859
 860	settleCore(claimant, pc)
 861}
 862
 863// SettleWithSignature is Settle, plus: if claimant's pinned round has not been published yet,
 864// this function publishes it ITSELF first, in this same transaction, using sigHex -- the round's
 865// drand signature, which the caller's own app fetches read-only from drand's public API
 866// (https://api.drand.sh/<chain>/public/<round> or any mirror) and passes straight through
 867// unmodified. This is the function a player's app should call once it has fetched a signature;
 868// Settle (above) remains for a caller with no signature to offer, or that already knows (or does
 869// not care) that somebody else has published the round.
 870//
 871// SAFE EITHER WAY THE ROUND TURNS OUT TO BE, which is the one property this function exists to
 872// guarantee and the live chain fact its shape is built around (see the package doc): beacon/v2's
 873// own Publish PANICS, not no-ops, when the round is already published, so this function checks
 874// beacon.Published first and only calls Publish if that check says no. If the round was published
 875// by someone else (another claimant's own SettleWithSignature, a relay, anyone) between the
 876// moment the caller fetched sigHex and the moment this transaction lands, sigHex is simply never
 877// used -- this function still succeeds, exactly as Settle would have. A bad sigHex, by contrast,
 878// is a real failure: beacon.Publish rejects it with its own "beacon: invalid signature" panic,
 879// which aborts this whole call (and the claim stays pending, to be retried with a correct
 880// signature or left for Settle once someone else relays it).
 881//
 882// Refuses coins for the same reason Settle does and every non-paying function here does: there
 883// is no banker to return them, and this is a free, permissionless computation -- the only payment
 884// anywhere in this call is the ordinary GNOT gas the caller's own transaction spends, to the
 885// network, never to this project (MINT.md section 14).
 886func SettleWithSignature(cur realm, claimant address, sigHex string) {
 887	if !unsafe.OriginSend().IsZero() {
 888		panic("garden: settle accepts no coins; it is a free, permissionless computation, not a payment")
 889	}
 890	if !claimant.IsValid() {
 891		panic("garden: settle requires a valid claimant address")
 892	}
 893
 894	pc := pendingClaimFor(claimant)
 895
 896	if !beacon.Published(pc.round) {
 897		// Permissionless and carries no caller-identity check (verified against the live
 898		// deployed beacon/v2 source -- see the package doc): this realm relaying on a player's
 899		// behalf is exactly as valid as the player relaying directly, or anyone else doing so.
 900		beacon.Publish(cross(cur), pc.round, sigHex)
 901	}
 902
 903	settleCore(claimant, pc)
 904}
 905
 906// settleCore is Settle's and SettleWithSignature's shared tail: the draw, the mint, and every
 907// write that follows, once the caller above has already confirmed the pinned round is published.
 908// Takes no `cur realm` because it makes no cross-realm call of its own -- beacon.Randomness and
 909// beacon.Published below are plain reads, not crossing functions, exactly as they always were
 910// before this change.
 911func settleCore(claimant address, pc *pendingClaim) {
 912	randomnessHex := beacon.Randomness(pc.round)
 913
 914	if mintedCount < settledCount+1 {
 915		// Unreachable: Claim never lets mintedCount fall behind settledCount+1 for a claim that
 916		// is about to settle, because mintedCount is incremented before any pendingClaim this
 917		// function could ever read exists. Named so this function is correct on its own terms,
 918		// the same discipline v4's Buy uses for its own unreachable checks.
 919		panic("garden: batch accounting is inconsistent; refusing to mint")
 920	}
 921
 922	// THE NAMED-EDITION PICK. seed % batchCap, against the COMPILE-TIME CONSTANT batchCap, never
 923	// against a shrinking count of what remains -- see the package doc's "flaw found and fixed"
 924	// section for why that distinction is the whole safety property here. namedPick is fixed the
 925	// instant randomnessHex exists (i.e. the instant the pinned round is signed by drand, which
 926	// nobody controls), and WHEN Settle happens to be called after that changes nothing about it.
 927	namedPick := seedFor(randomnessHex, claimant, "slot") % uint64(batchCap)
 928
 929	// The ordinary draw is always computed, unconditionally, whether or not namedPick lands in
 930	// the named range -- so a claimant whose pick collides with an edition someone else already
 931	// settled first has a ready, equally valid fallback rather than needing a second derivation.
 932	eyesSeed := seedFor(randomnessHex, claimant, "eyes")
 933	noseSeed := seedFor(randomnessHex, claimant, "nose")
 934	hatCoatSeed := seedFor(randomnessHex, claimant, "hatcoat")
 935	drawnEyes := int64(drawWeighted(eyesWeights[:], 100, eyesSeed))
 936	drawnNose := int64(drawWeighted(noseWeights[:], 100, noseSeed))
 937	drawnHat := hatOptions[hatCoatSeed%uint64(len(hatOptions))]
 938	drawnCoat := coatOptions[(hatCoatSeed/uint64(len(hatOptions)))%uint64(len(coatOptions))]
 939
 940	character := ""
 941	eyes, nose := drawnEyes, drawnNose
 942	hat, coat := drawnHat, drawnCoat
 943	if namedPick < uint64(numNamedEditions) {
 944		candidate := namedEditionOrder[namedPick]
 945		if !characterOwner.Has(candidate) {
 946			// Whichever Settle call lands on chain first among possibly several colliding
 947			// claimants gets this edition; a later one finds characterOwner already holds it and
 948			// falls through to its own ordinary draw instead. This is a transaction-ordering
 949			// race, not a strategy any claimant can win by choosing WHEN to settle their OWN
 950			// claim -- their own pick is already fixed and cannot change -- see the package doc's
 951			// "a real flaw found and fixed" section for why claimedAt-based tie-breaking was
 952			// considered and rejected, and for the precise property this guarantees instead.
 953			character = candidate
 954			eyes, nose = -1, -1
 955			hat, coat = "", ""
 956		}
 957	}
 958
 959	settledCount++
 960
 961	id := nextID.Next()
 962	tid := grc721.TokenID(id.String())
 963	if err := ledger.Mint(claimant, tid); err != nil {
 964		panic(err)
 965	}
 966
 967	now := time.Now().Unix()
 968	gnomes.Set(tid.String(), &gnome{
 969		hat:          hat,
 970		coat:         coat,
 971		eyes:         eyes,
 972		nose:         nose,
 973		character:    character,
 974		level:        1,
 975		adoptedAt:    now,
 976		belly:        meterMax,
 977		bellyAt:      now,
 978		cheer:        meterMax,
 979		cheerAt:      now,
 980		lastActionAt: now,
 981		lastWokenAt:  now,
 982		acquiredAt:   now,
 983	})
 984
 985	addOwnerToken(claimant, tid.String())
 986	if character != "" {
 987		characterOwner.Set(character, tid.String())
 988	} else {
 989		eyesCounts.Set(strconv.FormatInt(eyes, 10), eyesCount(eyes)+1)
 990		noseCounts.Set(strconv.FormatInt(nose, 10), noseCount(nose)+1)
 991		hatCounts.Set(hat, hatCount(hat)+1)
 992		coatCounts.Set(coat, coatCount(coat)+1)
 993	}
 994
 995	grantStartingPurseIfNew(claimant)
 996
 997	// Auto-wake only if this claimant currently has nothing awake at all (never settled or
 998	// bought a gnome before, or sold their only one). Never otherwise: a claimant who already
 999	// has an awake gnome gets this one asleep in their stash, exactly as a purchase would (Buy's
1000	// own comment explains why switching must always be a deliberate Wake, never a side effect).
1001	if !activeToken.Has(claimant.String()) {
1002		activeToken.Set(claimant.String(), tid.String())
1003	}
1004
1005	pendingClaims.Remove(claimant.String())
1006
1007	chain.Emit(
1008		"Settle",
1009		"claimant", claimant.String(),
1010		"tokenId", tid.String(),
1011		"round", strconv.FormatInt(pc.round, 10),
1012		"character", character,
1013		"eyes", strconv.FormatInt(eyes, 10),
1014		"nose", strconv.FormatInt(nose, 10),
1015		"hat", hat,
1016		"coat", coat,
1017	)
1018}
1019
1020// fnv64a is a plain, non-cryptographic 64-bit FNV-1a hash. IT IS NOT THE SOURCE OF
1021// UNPREDICTABILITY HERE -- drand's signature for a not-yet-existent round is, exactly as MINT.md
1022// section 10.5 designs. This exists only to DECORRELATE independent draws (two different
1023// claimants sharing a pinned round, or one claimant's own eyes-draw from their own nose-draw)
1024// from a single public drand value, the same category of use math/rand's PCG(0,0) would be fine
1025// for once combined with a secure external seed -- see MINT.md section 10.5's own list of unsafe
1026// constructions for why math/rand ALONE is not used, and note this is never alone: every call
1027// site below mixes in beacon.Randomness(pc.round), which is the only thing doing real work.
1028func fnv64a(s string) uint64 {
1029	const offset64 = uint64(14695981039346656037)
1030	const prime64 = uint64(1099511628211)
1031	h := offset64
1032	for i := 0; i < len(s); i++ {
1033		h ^= uint64(s[i])
1034		h *= prime64
1035	}
1036	return h
1037}
1038
1039// seedFor derives one independent-looking 64-bit value from the published drand randomness, the
1040// claimant's own address, and a purpose string -- so a single drand round can safely drive
1041// several unrelated draws (which named edition, which eyes, which nose, which hat/coat) for
1042// several different simultaneous claimants without any of them correlating with each other.
1043func seedFor(randomnessHex string, claimant address, purpose string) uint64 {
1044	return fnv64a(randomnessHex + "|" + claimant.String() + "|" + purpose)
1045}
1046
1047// drawWeighted picks an index into weights (which TestWeightTablesSumToOneHundred pins as
1048// summing to totalWeight) using seed as the source of randomness -- a plain cumulative-sum
1049// bucket walk, MINT.md section 10.5's own recommendation for what Settle should do ("a simple,
1050// bounded index... easy to audit"), applied to a weight table instead of a published array,
1051// which is the same shape of arithmetic.
1052func drawWeighted(weights []int64, totalWeight int64, seed uint64) int {
1053	if totalWeight <= 0 {
1054		panic("garden: drawWeighted: totalWeight must be positive")
1055	}
1056	x := int64(seed % uint64(totalWeight))
1057	var cum int64
1058	for i, w := range weights {
1059		cum += w
1060		if x < cum {
1061			return i
1062		}
1063	}
1064	// Unreachable if weights sums to exactly totalWeight, which the test above pins. Returning
1065	// the last index rather than panicking keeps Settle from ever aborting a claimant's one and
1066	// only roll over an arithmetic slip elsewhere in this file.
1067	return len(weights) - 1
1068}
1069
1070// ============================================================================
1071// Wake: switching which gnome is awake. MINT.md section 2a.
1072// ============================================================================
1073
1074// Wake makes tokenID the caller's awake gnome. Free, and limited to once per UTC day -- the same
1075// day-boundary discipline SaveDay's own dailyCareDay already uses, chosen over a GNOT fee for the
1076// reason MINT.md section 2a gives: "a cost that cannot be bought, only lived".
1077func Wake(cur realm, tokenID string) {
1078	if !unsafe.OriginSend().IsZero() {
1079		panic("garden: wake accepts no coins; switching which gnome is awake is free, once per UTC day")
1080	}
1081
1082	if tokenID == genesisTokenID {
1083		panic("garden: the genesis gnome belongs to nobody and can never be woken")
1084	}
1085
1086	caller := cur.Previous().Address()
1087	if !caller.IsValid() {
1088		panic("garden: wake requires a valid caller address")
1089	}
1090
1091	owner, ok := ownerOfToken(tokenID)
1092	if !ok || owner != caller {
1093		panic("garden: you do not own that gnome")
1094	}
1095
1096	if active, hasActive := activeToken.Get(caller.String()).(string); hasActive && active == tokenID {
1097		panic("garden: that gnome is already awake")
1098	}
1099
1100	now := time.Now().Unix()
1101	today := dayNumber(now)
1102	if last, isInt := lastWakeDay.Get(caller.String()).(int64); isInt && last == today {
1103		panic("garden: switching which gnome is awake is limited to once per UTC day; try again tomorrow")
1104	}
1105
1106	// g must exist: ownerOfToken above already confirmed tokenID is a real, owned token.
1107	// Recording lastWokenAt HERE, on the token actually being woken, is the other half of the
1108	// fix for ATTACK-REVIEW.md goal 7 -- see the gnome struct's own comment on the field. The
1109	// token being put to sleep (whichever was active(caller) a moment ago, if any) needs no
1110	// symmetrical write: its own lastActionAt/caredDayCount are already frozen facts the instant
1111	// it stops being the active one (SaveDay can only ever act on activeGnomeForAddr's result),
1112	// and the next time anything reactivates IT, that reactivation (another Wake, a later Buy by
1113	// a new owner, etc.) will set its lastWokenAt correctly at that time, exactly as this call is
1114	// doing now.
1115	g, ok := gnomeByTokenID(tokenID)
1116	if !ok {
1117		panic("garden: that gnome does not exist")
1118	}
1119	g.lastWokenAt = now
1120	gnomes.Set(tokenID, g)
1121
1122	activeToken.Set(caller.String(), tokenID)
1123	lastWakeDay.Set(caller.String(), today)
1124
1125	chain.Emit("Wake", "owner", caller.String(), "tokenId", tokenID)
1126}
1127
1128// ============================================================================
1129// SaveDay: v4's shape, exactly, resolved through the caller's ACTIVE gnome rather than "the"
1130// gnome for that address -- because after mint one/hold many/play one, there no longer is one.
1131// A sleeping gnome is simply never found here: activeGnomeForAddr only ever returns the awake
1132// token, so care applied to "my gnome" always means the one gnome actually allowed to earn
1133// anything (MINT.md section 2a: "a sleeping gnome earns zero -- no cared days, no streak, no
1134// level, no XP").
1135// ============================================================================
1136
1137type saveAction struct {
1138	kind string
1139	at   int64
1140	plot int
1141}
1142
1143func SaveDay(cur realm, actionsRaw string) {
1144	if !unsafe.OriginSend().IsZero() {
1145		panic("garden: save day accepts no coins; only Buy takes payment, and nothing else here can return them")
1146	}
1147
1148	caller := cur.Previous().Address()
1149	if !caller.IsValid() {
1150		panic("garden: save day requires a valid caller address")
1151	}
1152
1153	tid, g, ok := activeGnomeForAddr(caller)
1154	if !ok {
1155		panic("garden: no gnome is awake for this address; claim or buy one, or Wake one of your own")
1156	}
1157
1158	now := time.Now().Unix()
1159	actions := parseActions(actionsRaw)
1160
1161	if len(actions) == 0 {
1162		chain.Emit("SaveDay", "owner", caller.String(), "tokenId", tid, "actions", "0", "xpApplied", "0")
1163		return
1164	}
1165
1166	earliestAllowed := now - maxBackfillHours*hourSeconds
1167
1168	// awakeSince: the moment THIS token last became the active one (set by settleCore, Buy, and
1169	// Wake -- see the gnome struct's own comment). ATTACK-REVIEW.md goal 7: without this, a
1170	// token's own earliestAllowed was bounded only by its own lastActionAt and the 48-hour
1171	// horizon, with nothing checking that the backfilled window actually fell inside a period
1172	// this specific token was awake. A token that just became active (whether by a brand-new
1173	// Claim/Settle, a Buy, or an explicit Wake switching away from a sibling in the same stable)
1174	// cannot backfill anything from before that moment -- it was asleep then, and MINT.md /
1175	// DECISIONS.md are both explicit that a sleeping gnome earns zero, full stop, not "zero
1176	// unless a sibling token happens to cover the gap."
1177	awakeSince := g.lastWokenAt
1178	if awakeSince > earliestAllowed {
1179		earliestAllowed = awakeSince
1180	}
1181
1182	firstDay := dayNumber(actions[0].at)
1183	prevAt := g.lastActionAt
1184	if prevAt < earliestAllowed {
1185		prevAt = earliestAllowed
1186	}
1187	for _, a := range actions {
1188		if a.at <= earliestAllowed {
1189			if a.at <= awakeSince {
1190				panic("garden: this action predates the last time this gnome was woken; a sleeping gnome earns nothing for the time it was asleep")
1191			}
1192			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")
1193		}
1194		if a.at <= prevAt {
1195			panic("garden: action timestamp must be strictly after the last saved action")
1196		}
1197		if a.at > now {
1198			panic("garden: action timestamp cannot be in the future")
1199		}
1200		if dayNumber(a.at) != firstDay {
1201			panic("garden: every action in one SaveDay call must fall on the same UTC day")
1202		}
1203		prevAt = a.at
1204	}
1205
1206	work := *g
1207	acorns := balanceOf(caller)
1208	spentThisCall := false
1209
1210	gateWaived := acorns < acornReserve && !anyPlotPlanted(g)
1211	rawCareXp := int64(0)
1212	tinkeredThisCall := false
1213
1214	for _, a := range actions {
1215		switch a.kind {
1216		case actionPat:
1217			if !canPat(work.lastPatAt, a.at, patCooldownHours) {
1218				panic("garden: pat must be at least " + strconv.FormatInt(patCooldownHours, 10) + " hours after the last one")
1219			}
1220			work.lastPatAt = a.at
1221			work.lastHappyEventAt = a.at
1222			work.cheer = clampMeter(meterNow(work.cheer, work.cheerAt, a.at, cheerDropEveryHours) + patCheer)
1223			work.cheerAt = a.at
1224			rawCareXp += xpPat
1225
1226		case actionFeed:
1227			if acorns < feedCost {
1228				panic("garden: not enough acorns to feed")
1229			}
1230			acorns -= feedCost
1231			spentThisCall = true
1232			work.belly = clampMeter(meterNow(work.belly, work.bellyAt, a.at, bellyDropEveryHours) + feedBelly)
1233			work.bellyAt = a.at
1234			work.lastHappyEventAt = a.at
1235			rawCareXp += xpFeed
1236
1237		case actionPlant:
1238			waived := gateWaived && acorns < acornReserve
1239			if !waived && meterNow(work.belly, work.bellyAt, a.at, bellyDropEveryHours) <= hungryAtOrBelow {
1240				panic("garden: too hungry to garden; feed first")
1241			}
1242			p := &work.plots[a.plot]
1243			if p.plantedAt != 0 {
1244				panic("garden: that plot is already planted")
1245			}
1246			if acorns < plantCost {
1247				panic("garden: not enough acorns to plant")
1248			}
1249			acorns -= plantCost
1250			spentThisCall = true
1251			p.plantedAt = a.at
1252			p.readyAt = a.at + growHours*hourSeconds
1253
1254		case actionHarvest:
1255			p := &work.plots[a.plot]
1256			if p.plantedAt == 0 {
1257				panic("garden: that plot has nothing to harvest")
1258			}
1259			if a.at < p.readyAt {
1260				panic("garden: that plot is not ready to harvest yet")
1261			}
1262			p.plantedAt = 0
1263			p.readyAt = 0
1264			acorns += harvestPerPlot
1265			work.xpGarden += xpHarvestPerPlot
1266			work.harvestCount++
1267			rawCareXp += xpHarvestPerPlot
1268
1269		case actionTinker:
1270			if tinkeredThisCall {
1271				panic("garden: only one tinker task per day")
1272			}
1273			if work.lastTinkerAt != 0 && dayNumber(work.lastTinkerAt) == dayNumber(a.at) {
1274				panic("garden: already did a tinker task today")
1275			}
1276			if acorns < tinkerCost {
1277				panic("garden: not enough acorns to tinker")
1278			}
1279			acorns -= tinkerCost
1280			spentThisCall = true
1281			work.xpTinker += xpTinkerTask
1282			work.lastTinkerAt = a.at
1283			tinkeredThisCall = true
1284			rawCareXp += xpTinkerTask
1285
1286		default:
1287			panic("garden: unknown action kind")
1288		}
1289	}
1290
1291	if spentThisCall && acorns < acornReserve && !anyPlotPlanted(&work) {
1292		panic("garden: that would leave fewer than " + strconv.FormatInt(acornReserve, 10) + " acorns with nothing planted, and a gnome with no acorns and no garden can never earn another; plant first, or keep " + strconv.FormatInt(acornReserve, 10) + " back")
1293	}
1294
1295	latestActionAt := actions[len(actions)-1].at
1296	bellyAtEnd := meterNow(work.belly, work.bellyAt, latestActionAt, bellyDropEveryHours)
1297	mood := moodNow(bellyAtEnd, work.lastPatAt, work.lastHappyEventAt, work.adoptedAt, latestActionAt, hungryAtOrBelow)
1298
1299	adjustedXp := rawCareXp
1300	switch mood {
1301	case moodHappy:
1302		adjustedXp = adjustedXp * (100 + happyBonusPct) / 100
1303	case moodSulky:
1304		adjustedXp = adjustedXp * (100 - sulkyPenaltyPct) / 100
1305	}
1306
1307	if work.dailyCareDay != firstDay {
1308		work.dailyCareDay = firstDay
1309		work.dailyCareXp = 0
1310	}
1311	remainingToday := dailyCareCap - work.dailyCareXp
1312	if remainingToday < 0 {
1313		remainingToday = 0
1314	}
1315	if adjustedXp > remainingToday {
1316		adjustedXp = remainingToday
1317	}
1318	work.dailyCareXp += adjustedXp
1319
1320	work.totalXp += adjustedXp
1321	work.level = levelFromXp(work.totalXp)
1322
1323	// At most ONE of this owner's tokens may ever bank a given UTC day as cared-for -- see
1324	// lastCaredDayForOwner's own comment. Without this, Wake (once per day, but still once)
1325	// lets an owner genuinely care for A earlier today, switch to B, and genuinely care for B
1326	// too, banking the SAME calendar day on both from nothing but real, non-backfilled
1327	// timestamps -- the live-switch half of ATTACK-REVIEW.md goal 7 that lastWokenAt's backfill
1328	// floor does not by itself prevent. XP, acorns, feeding, planting and harvesting are
1329	// deliberately NOT gated here: those are this token's own resource mechanics (already
1330	// bounded per-gnome by dailyCareCap) and giving B real care today is still real care: only
1331	// the CARED-DAY LEDGER -- the thing lockCaredDays and the public streak actually count -- is
1332	// restricted to one credit per owner per day.
1333	if lastOwnerDay, ok := lastCaredDayForOwner.Get(caller.String()).(int64); !ok || lastOwnerDay != firstDay {
1334		if recordCaredDay(&work, firstDay) {
1335			lastCaredDayForOwner.Set(caller.String(), firstDay)
1336		}
1337	}
1338	work.lastActionAt = latestActionAt
1339
1340	setBalance(caller, acorns)
1341	*g = work
1342	gnomes.Set(tid, g)
1343
1344	chain.Emit(
1345		"SaveDay",
1346		"owner", caller.String(),
1347		"tokenId", tid,
1348		"actions", strconv.Itoa(len(actions)),
1349		"xpApplied", strconv.FormatInt(adjustedXp, 10),
1350		"mood", mood,
1351	)
1352}
1353
1354// ============================================================================
1355// ChooseType: v4's shape, resolved through the active gnome.
1356// ============================================================================
1357
1358func ChooseType(cur realm, kind string) {
1359	if !unsafe.OriginSend().IsZero() {
1360		panic("garden: choose type accepts no coins; only Buy takes payment, and nothing else here can return them")
1361	}
1362
1363	caller := cur.Previous().Address()
1364	if !caller.IsValid() {
1365		panic("garden: choose type requires a valid caller address")
1366	}
1367
1368	tid, g, ok := activeGnomeForAddr(caller)
1369	if !ok {
1370		panic("garden: no gnome is awake for this address")
1371	}
1372
1373	if g.gnomeType != "" {
1374		panic("garden: this gnome's calling is already chosen and cannot be changed")
1375	}
1376
1377	level := levelFromXp(g.totalXp)
1378	if level < typeAtLevel {
1379		panic("garden: a calling can only be chosen at level " + strconv.FormatInt(typeAtLevel, 10) + " or above")
1380	}
1381
1382	if kind != typeGardener && kind != typeExplorer && kind != typeTinkerer {
1383		panic("garden: a calling is one of \"" + typeGardener + "\", \"" + typeExplorer + "\" or \"" + typeTinkerer + "\"")
1384	}
1385
1386	work := *g
1387	work.gnomeType = kind
1388	work.level = level
1389	*g = work
1390	gnomes.Set(tid, g)
1391
1392	chain.Emit("ChooseType", "owner", caller.String(), "tokenId", tid, "type", kind)
1393}
1394
1395// ============================================================================
1396// SALE: List/Cancel/Buy. v4's shape, DESIGN.md section 9's reasoning unchanged, minus the
1397// one-gnome-per-address refusal in Buy (MINT.md section 2a: removed from Buy, never from Claim)
1398// and plus a tokenID parameter on List/Cancel, because an owner may now have more than one
1399// gnome to choose among, and plus the lockCaredDays check on the three named one-of-ones.
1400// ============================================================================
1401
1402// List offers tokenID, which the caller must own, for sale. The lock: a named one-of-one
1403// (character != "") cannot be listed until it has lockCaredDays cared days; an ordinary gnome
1404// has no such restriction, trading from the moment it is minted (MINT.md section 12/13).
1405func List(cur realm, tokenID string, priceUgnot int64, buyer address, days int64) {
1406	if !unsafe.OriginSend().IsZero() {
1407		panic("garden: list accepts no coins; only Buy takes payment")
1408	}
1409
1410	if tokenID == genesisTokenID {
1411		panic("garden: the genesis gnome belongs to nobody and can never be listed")
1412	}
1413
1414	caller := cur.Previous().Address()
1415	if !caller.IsValid() {
1416		panic("garden: list requires a valid caller address")
1417	}
1418
1419	owner, ok := ownerOfToken(tokenID)
1420	if !ok || owner != caller {
1421		panic("garden: you do not own that gnome")
1422	}
1423
1424	gv := gnomes.Get(tokenID)
1425	g, isGnome := gv.(*gnome)
1426	if !isGnome || g == nil {
1427		panic("garden: that tokenID does not exist")
1428	}
1429
1430	if g.character != "" && g.caredDayCount < lockCaredDays {
1431		panic("garden: " + g.character + " is one of the three named one-of-ones and cannot be listed until it has " +
1432			strconv.FormatInt(lockCaredDays, 10) + " cared days; it has " + strconv.FormatInt(g.caredDayCount, 10))
1433	}
1434
1435	if priceUgnot < 0 {
1436		panic("garden: a price cannot be negative")
1437	}
1438	if priceUgnot > maxPriceUgnot {
1439		panic("garden: price exceeds the maximum of " + strconv.FormatInt(maxPriceUgnot, 10) + " ugnot")
1440	}
1441	if days < 1 || days > maxListingDays {
1442		panic("garden: a listing lasts between 1 and " + strconv.FormatInt(maxListingDays, 10) + " days")
1443	}
1444
1445	buyer = address(lowerASCII(string(buyer)))
1446	if buyer != address("") {
1447		if !buyer.IsValid() {
1448			panic("garden: reserved buyer is not a valid address; pass an empty address to let anyone buy")
1449		}
1450		if buyer == caller {
1451			panic("garden: you cannot reserve your own gnome for yourself")
1452		}
1453	}
1454
1455	now := time.Now().Unix()
1456	listings.Set(tokenID, &listing{
1457		seller:     caller,
1458		priceUgnot: priceUgnot,
1459		buyer:      buyer,
1460		listedAt:   now,
1461		expiresAt:  now + days*daySeconds,
1462	})
1463
1464	feeUgnot := feeFor(priceUgnot)
1465	chain.Emit(
1466		"List",
1467		"seller", caller.String(),
1468		"tokenId", tokenID,
1469		"priceUgnot", strconv.FormatInt(priceUgnot, 10),
1470		"feeUgnot", strconv.FormatInt(feeUgnot, 10),
1471		"totalUgnot", strconv.FormatInt(priceUgnot+feeUgnot, 10),
1472		"buyer", buyer.String(),
1473		"expiresAt", strconv.FormatInt(now+days*daySeconds, 10),
1474	)
1475}
1476
1477// Cancel withdraws the caller's own listing on tokenID. Only the seller who created it may.
1478func Cancel(cur realm, tokenID string) {
1479	if !unsafe.OriginSend().IsZero() {
1480		panic("garden: cancel accepts no coins; only Buy takes payment")
1481	}
1482
1483	caller := cur.Previous().Address()
1484	if !caller.IsValid() {
1485		panic("garden: cancel requires a valid caller address")
1486	}
1487
1488	l, ok := listingFor(tokenID)
1489	if !ok {
1490		panic("garden: that gnome is not for sale")
1491	}
1492	if l.seller != caller {
1493		panic("garden: only the seller can cancel their own listing")
1494	}
1495
1496	listings.Remove(tokenID)
1497	chain.Emit("Cancel", "seller", caller.String(), "tokenId", tokenID)
1498}
1499
1500// Buy purchases the listed gnome tokenID. v4's validate-then-commit shape, unchanged, MINUS the
1501// one-gnome-per-address refusal: MINT.md section 2a removes the hold cap from Buy specifically,
1502// and only from Buy -- Claim's hasRolled gate is the cap that still matters, and this function
1503// never touches it.
1504func Buy(cur realm, tokenID string) {
1505	if !cur.Previous().IsUserCall() {
1506		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")
1507	}
1508	buyer := cur.Previous().Address()
1509	if !buyer.IsValid() {
1510		panic("garden: buy requires a valid caller address")
1511	}
1512
1513	if tokenID == genesisTokenID {
1514		panic("garden: the genesis gnome belongs to nobody and can never be bought")
1515	}
1516
1517	l, ok := listingFor(tokenID)
1518	if !ok {
1519		panic("garden: that gnome is not for sale")
1520	}
1521
1522	now := time.Now().Unix()
1523	if now > l.expiresAt {
1524		panic("garden: that listing has expired; the seller must list again")
1525	}
1526	if l.seller == buyer {
1527		panic("garden: you cannot buy your own gnome")
1528	}
1529	if l.buyer != address("") && l.buyer != buyer {
1530		panic("garden: that gnome is reserved for a different buyer")
1531	}
1532
1533	tid := grc721.TokenID(tokenID)
1534	owner, err := token.OwnerOf(tid)
1535	if err != nil || owner != l.seller {
1536		panic("garden: that listing is stale; the seller no longer owns that gnome")
1537	}
1538	gv := gnomes.Get(tokenID)
1539	g, isGnome := gv.(*gnome)
1540	if !isGnome || g == nil {
1541		panic("garden: that listing names a gnome this realm does not hold")
1542	}
1543
1544	feeUgnot := feeFor(l.priceUgnot)
1545	total := l.priceUgnot + feeUgnot
1546	send := unsafe.OriginSend()
1547	if !exactUgnot(send, total) {
1548		panic("garden: buy requires exactly " + strconv.FormatInt(total, 10) + ugnotDenom +
1549			" attached (" + strconv.FormatInt(l.priceUgnot, 10) + " price + " +
1550			strconv.FormatInt(feeUgnot, 10) + " fee); got " + coinsString(send))
1551	}
1552
1553	// ---- commit. Nothing below this line can fail on chain. ----
1554
1555	transferGate = tokenID
1556	if err := ledger.TransferFrom(l.seller, l.seller, buyer, tid); err != nil {
1557		panic(err)
1558	}
1559	transferGate = ""
1560
1561	removeOwnerToken(l.seller, tokenID)
1562	addOwnerToken(buyer, tokenID)
1563	listings.Remove(tokenID)
1564
1565	// Active-token bookkeeping (mint one, hold many, play one). If this was the seller's awake
1566	// gnome, they fall asleep entirely rather than another of their gnomes quietly becoming
1567	// active -- switching is always a deliberate Wake, never a side effect of a sale. If the
1568	// buyer currently has nothing awake at all, this purchase becomes their active gnome
1569	// automatically, so a first-time buyer is never left with nothing playable.
1570	if av, isActive := activeToken.Get(l.seller.String()).(string); isActive && av == tokenID {
1571		activeToken.Remove(l.seller.String())
1572	}
1573	if !activeToken.Has(buyer.String()) {
1574		activeToken.Set(buyer.String(), tokenID)
1575	}
1576
1577	settled := int64(0)
1578	for i := 0; i < numPlots; i++ {
1579		p := &g.plots[i]
1580		if p.plantedAt == 0 {
1581			continue
1582		}
1583		if now >= p.readyAt {
1584			settled += harvestPerPlot
1585		} else {
1586			settled += plantCost
1587		}
1588		p.plantedAt = 0
1589		p.readyAt = 0
1590	}
1591	if settled > 0 {
1592		setBalance(l.seller, balanceOf(l.seller)+settled)
1593	}
1594
1595	g.acquiredAt = now
1596	g.saleCount++
1597	g.lastActionAt = now
1598	// lastWokenAt resets here too, same as lastActionAt, whether or not this purchase becomes
1599	// the buyer's active gnome below -- if it does not (buyer already has something awake), this
1600	// value is simply overwritten by the eventual explicit Wake that does activate it. Either
1601	// way, nothing the SELLER (or anyone else) did with this token before the sale can be
1602	// backfilled by the BUYER afterward: SaveDay's floor cannot reach earlier than this moment.
1603	g.lastWokenAt = now
1604	gnomes.Set(tokenID, g)
1605
1606	grantStartingPurseIfNew(buyer)
1607
1608	bank := banker.NewBanker(banker.BankerTypeOriginSend, cur)
1609	me := cur.Address()
1610	if l.priceUgnot > 0 {
1611		bank.SendCoins(me, l.seller, chain.Coins{{Denom: ugnotDenom, Amount: l.priceUgnot}})
1612	}
1613	bank.SendCoins(me, fee.recipient, chain.Coins{{Denom: ugnotDenom, Amount: feeUgnot}})
1614
1615	chain.Emit(
1616		"Sale",
1617		"tokenId", tokenID,
1618		"seller", l.seller.String(),
1619		"buyer", buyer.String(),
1620		"priceUgnot", strconv.FormatInt(l.priceUgnot, 10),
1621		"feeUgnot", strconv.FormatInt(feeUgnot, 10),
1622		"feeRecipient", fee.recipient.String(),
1623		"settledAcorns", strconv.FormatInt(settled, 10),
1624	)
1625}
1626
1627// ============================================================================
1628// Readers
1629// ============================================================================
1630
1631func Listing(tokenID string) (seller address, priceUgnot int64, buyer address, listedAtUnix, expiresAtUnix, feeUgnot, totalUgnot int64, found bool) {
1632	l, ok := listingFor(tokenID)
1633	if !ok {
1634		return address(""), 0, address(""), 0, 0, 0, 0, false
1635	}
1636	fee := feeFor(l.priceUgnot)
1637	return l.seller, l.priceUgnot, l.buyer, l.listedAt, l.expiresAt, fee, l.priceUgnot + fee, true
1638}
1639
1640func TotalListings() int64 {
1641	return int64(listings.Size())
1642}
1643
1644func ListingsPage(offset, limit int) []string {
1645	if offset < 0 {
1646		panic("garden: offset must not be negative")
1647	}
1648	if limit <= 0 {
1649		panic("garden: limit must be positive")
1650	}
1651	if limit > maxListingsPageSize {
1652		limit = maxListingsPageSize
1653	}
1654	tokenIDs := make([]string, 0, limit)
1655	listings.IterateByOffset(offset, limit, func(key string, value any) bool {
1656		tokenIDs = append(tokenIDs, key)
1657		return false
1658	})
1659	return tokenIDs
1660}
1661
1662func QuoteFee(priceUgnot int64) (feeUgnot, totalUgnot int64) {
1663	f := feeFor(priceUgnot)
1664	return f, priceUgnot + f
1665}
1666
1667func FeeRules() string {
1668	return "saleFeeRateBps=" + strconv.FormatInt(fee.rateBps, 10) +
1669		";saleFeeFloorUgnot=" + strconv.FormatInt(fee.floorUgnot, 10) +
1670		";saleFeeRecipient=" + fee.recipient.String()
1671}
1672
1673func Provenance(tokenID string) (adoptedAtUnix, acquiredAtUnix, saleCount int64, found bool) {
1674	g, ok := gnomeByTokenID(tokenID)
1675	if !ok {
1676		return 0, 0, 0, false
1677	}
1678	return g.adoptedAt, g.acquiredAt, g.saleCount, true
1679}
1680
1681// GnomeByToken: the basic facts for one token -- hat, coat, eyes, nose, character (empty for an
1682// ordinary gnome), level, calling and the moment it was minted.
1683func GnomeByToken(tokenID string) (hat, coat string, eyes, nose int64, character string, level int64, gnomeType string, adoptedAtUnix int64, found bool) {
1684	g, ok := gnomeByTokenID(tokenID)
1685	if !ok {
1686		return "", "", 0, 0, "", 0, "", 0, false
1687	}
1688	return g.hat, g.coat, g.eyes, g.nose, g.character, g.level, g.gnomeType, g.adoptedAt, true
1689}
1690
1691// ActiveGnome returns the tokenID that is awake for addr right now, or found=false if nothing
1692// is (never claimed or bought a gnome, or sold the only one and has not Woken another since).
1693func ActiveGnome(addr address) (tokenID string, found bool) {
1694	tid, isStr := activeToken.Get(addr.String()).(string)
1695	if !isStr || tid == "" {
1696		return "", false
1697	}
1698	return tid, true
1699}
1700
1701// OwnerTokens returns every tokenID addr currently holds, in no particular order. Unbounded and
1702// unpaginated deliberately: batchCap is 24 and the genesis gnome is permanently unreachable to
1703// any wallet, so no address can ever hold more than batchCap tokens, which is a small, fixed
1704// upper bound this call is always safely within.
1705func OwnerTokens(addr address) []string {
1706	v := ownerTokens.Get(addr.String())
1707	list, ok := v.(*[]string)
1708	if !ok || list == nil {
1709		return []string{}
1710	}
1711	out := make([]string, len(*list))
1712	copy(out, *list)
1713	return out
1714}
1715
1716// TokensPage: the global token enumerator, hollowmere/v1's own pattern (TotalClaimants/
1717// ClaimantsPage) applied here, because v4's own missing-enumerator lesson (DECISIONS.md:780-791,
1718// arena/v1 shipping with no challenge inbox and no way to ever add one) is unavailable to fix
1719// after deploy and twenty lines now is cheaper than "unavailable forever" later.
1720func TokensPage(offset, limit int) []string {
1721	if offset < 0 {
1722		panic("garden: offset must not be negative")
1723	}
1724	if limit <= 0 {
1725		panic("garden: limit must be positive")
1726	}
1727	if limit > maxGnomesPageSize {
1728		limit = maxGnomesPageSize
1729	}
1730	ids := make([]string, 0, limit)
1731	gnomes.IterateByOffset(offset, limit, func(key string, value any) bool {
1732		ids = append(ids, key)
1733		return false
1734	})
1735	return ids
1736}
1737
1738func GardenState(tokenID string) (plantedAtCsv, readyAtCsv string, readyCount int64, found bool) {
1739	g, ok := gnomeByTokenID(tokenID)
1740	if !ok {
1741		return "", "", 0, false
1742	}
1743	now := time.Now().Unix()
1744	planted := make([]string, 0, numPlots)
1745	ready := make([]string, 0, numPlots)
1746	for i := 0; i < numPlots; i++ {
1747		p := g.plots[i]
1748		planted = append(planted, strconv.FormatInt(p.plantedAt, 10))
1749		ready = append(ready, strconv.FormatInt(p.readyAt, 10))
1750		if p.plantedAt != 0 && now >= p.readyAt {
1751			readyCount++
1752		}
1753	}
1754	return strings.Join(planted, ","), strings.Join(ready, ","), readyCount, true
1755}
1756
1757func CareState(tokenID string) (belly, cheer, acorns, totalXp, lastPatAtUnix, lastHappyEventAtUnix, lastActionAtUnix int64, found bool) {
1758	g, ok := gnomeByTokenID(tokenID)
1759	if !ok {
1760		return 0, 0, 0, 0, 0, 0, 0, false
1761	}
1762	owner, hasOwner := ownerOfToken(tokenID)
1763	acornBalance := int64(0)
1764	if hasOwner {
1765		acornBalance = balanceOf(owner)
1766	}
1767	now := time.Now().Unix()
1768	return meterNow(g.belly, g.bellyAt, now, bellyDropEveryHours),
1769		meterNow(g.cheer, g.cheerAt, now, cheerDropEveryHours),
1770		acornBalance, g.totalXp, g.lastPatAt, g.lastHappyEventAt, g.lastActionAt, true
1771}
1772
1773func MeterState(tokenID string) (belly, bellyAtUnix, cheer, cheerAtUnix int64, found bool) {
1774	g, ok := gnomeByTokenID(tokenID)
1775	if !ok {
1776		return 0, 0, 0, 0, false
1777	}
1778	return g.belly, g.bellyAt, g.cheer, g.cheerAt, true
1779}
1780
1781func Progress(tokenID string) (xpGarden, xpTinker, harvestCount, lastTinkerAtUnix, caredDayCount, dailyCareXpSpent, dailyCareDay int64, found bool) {
1782	g, ok := gnomeByTokenID(tokenID)
1783	if !ok {
1784		return 0, 0, 0, 0, 0, 0, 0, false
1785	}
1786	return g.xpGarden, g.xpTinker, g.harvestCount, g.lastTinkerAt,
1787		g.caredDayCount, g.dailyCareXp, g.dailyCareDay, true
1788}
1789
1790func CaredDays(tokenID string) (daysCsv string, found bool) {
1791	g, ok := gnomeByTokenID(tokenID)
1792	if !ok {
1793		return "", false
1794	}
1795	return caredDaysCsv(g), true
1796}
1797
1798func Mood(tokenID string) (mood string, found bool) {
1799	g, ok := gnomeByTokenID(tokenID)
1800	if !ok {
1801		return "", false
1802	}
1803	now := time.Now().Unix()
1804	belly := meterNow(g.belly, g.bellyAt, now, bellyDropEveryHours)
1805	return moodNow(belly, g.lastPatAt, g.lastHappyEventAt, g.adoptedAt, now, hungryAtOrBelow), true
1806}
1807
1808// IsLocked reports whether tokenID is currently refused by List's lockCaredDays check -- true
1809// only for a named one-of-one with fewer than lockCaredDays cared days; false for every ordinary
1810// gnome, always, and false for a named one-of-one once it has enough cared days.
1811func IsLocked(tokenID string) (locked bool, found bool) {
1812	g, ok := gnomeByTokenID(tokenID)
1813	if !ok {
1814		return false, false
1815	}
1816	return g.character != "" && g.caredDayCount < lockCaredDays, true
1817}
1818
1819func CollectionInfo() (name, symbol string) {
1820	return collectionName, collectionSymbol
1821}
1822
1823func TotalSupply() int64 {
1824	return token.TotalSupply()
1825}
1826
1827func BalanceOf(addr address) int64 {
1828	n, err := token.BalanceOf(addr)
1829	if err != nil {
1830		return 0
1831	}
1832	return n
1833}
1834
1835func OwnerOfToken(tokenID string) (owner address, found bool) {
1836	return ownerOfToken(tokenID)
1837}
1838
1839func TotalGnomes() int64 {
1840	return token.TotalSupply()
1841}
1842
1843func Acorns(addr address) (acorns int64, found bool) {
1844	if !balances.Has(addr.String()) {
1845		return 0, false
1846	}
1847	return balanceOf(addr), true
1848}
1849
1850// ---- counted rarity readers (MINT.md section 13): "nine gnomes in the world have this nose"
1851// has to be a chain fact. These four, plus CharacterOwner below, are that fact.
1852
1853func EyesCount(id int64) int64    { return eyesCount(id) }
1854func NoseCount(id int64) int64    { return noseCount(id) }
1855func HatCount(hat string) int64   { return hatCount(hat) }
1856func CoatCount(coat string) int64 { return coatCount(coat) }
1857
1858// CharacterOwner resolves one of the three named one-of-ones, or the genesis gnome under its
1859// own name "genesis", to its tokenID, once minted. (Second-audit-pass nitpick: an earlier
1860// version of this comment said only "one of the three named one-of-ones", which stopped being
1861// true the moment init() registered "genesis" here too -- see init()'s own comment for why that
1862// registration is safe and never touches the three editions' cap.)
1863func CharacterOwner(name string) (tokenID string, found bool) {
1864	v := characterOwner.Get(name)
1865	s, ok := v.(string)
1866	if !ok {
1867		return "", false
1868	}
1869	return s, true
1870}
1871
1872// ---- the mint's own state, read back.
1873
1874func IsAllowlisted(addr address) bool {
1875	return allowlist.Has(addr.String())
1876}
1877
1878func HasRolled(addr address) bool {
1879	return hasRolled.Has(addr.String())
1880}
1881
1882func PendingClaim(addr address) (round, claimedAtUnix int64, found bool) {
1883	v := pendingClaims.Get(addr.String())
1884	pc, ok := v.(*pendingClaim)
1885	if !ok || pc == nil {
1886		return 0, 0, false
1887	}
1888	return pc.round, pc.claimedAt, true
1889}
1890
1891// BatchStatus: mintedCount is reservations made by Claim (gates batchCap); settledCount is
1892// completions made by Settle (always <= mintedCount, the gap being pending, unsettled claims);
1893// namedMinted is how many of the three named one-of-ones have actually been minted so far --
1894// read live off characterOwner rather than a separate counter, so there is only ever one source
1895// of truth for "has this edition been taken".
1896func BatchStatus() (mintedSoFar, settledSoFar, batchCapOut, namedMinted, namedCapOut int64) {
1897	// NAMED RETURNS DELIBERATELY DO NOT SHARE A NAME WITH THE PACKAGE-LEVEL mintedCount/
1898	// settledCount VARS. An earlier version of this function used mintedCount/settledCount as
1899	// its own named returns, which SHADOWED the package vars for the whole function body, so
1900	// `return mintedCount, settledCount, ...` returned the zero-valued local named returns
1901	// instead of the real package state -- this function always reported a fresh batch,
1902	// silently, with no error, which TestBatchClosesAtCapAndRefusesFurtherClaims caught by
1903	// actually driving claims until the realm said the batch was full and finding it never did.
1904	// Found and fixed before audit; recorded here rather than silently corrected, the same
1905	// discipline this file's own package doc uses for the Settle timing flaw.
1906	nm := int64(0)
1907	for _, name := range namedEditionOrder {
1908		if characterOwner.Has(name) {
1909			nm++
1910		}
1911	}
1912	return mintedCount, settledCount, batchCap, nm, numNamedEditions
1913}
1914
1915func GenesisTokenID() string {
1916	return genesisTokenID
1917}
1918
1919func AdminState() (admin address, frozen bool) {
1920	return address(adminAddress), rulesFrozen
1921}
1922
1923func LastWakeDay(addr address) (day int64, found bool) {
1924	v, ok := lastWakeDay.Get(addr.String()).(int64)
1925	return v, ok
1926}
1927
1928// ============================================================================
1929// Render
1930// ============================================================================
1931
1932func Render(path string) string {
1933	if path != "" {
1934		return "garden/v5: unknown path"
1935	}
1936	minted, settled, cap_, namedMinted, namedCap := BatchStatus()
1937	out := "# Gnotchi gnomes -- garden/v5\n\n"
1938	out += "Batch One: " + strconv.FormatInt(minted, 10) + " claimed, " +
1939		strconv.FormatInt(settled, 10) + " settled, of " + strconv.FormatInt(cap_, 10) + " ever.\n\n"
1940	out += "Named one-of-ones minted: " + strconv.FormatInt(namedMinted, 10) + " of " + strconv.FormatInt(namedCap, 10) + ".\n\n"
1941	out += "Total supply (including the genesis gnome): " + strconv.FormatInt(TotalSupply(), 10) + "\n\n"
1942	out += "Free to claim, if your address is on the allowlist. One roll per wallet, ever. " +
1943		"Claim pins a future drand round; Settle, callable by anyone once that round is " +
1944		"published, mints the result.\n"
1945	return out
1946}
1947
1948// ============================================================================
1949// Internal helpers
1950// ============================================================================
1951
1952func gnomeByTokenID(tokenID string) (*gnome, bool) {
1953	v := gnomes.Get(tokenID)
1954	g, ok := v.(*gnome)
1955	if !ok || g == nil {
1956		return nil, false
1957	}
1958	return g, true
1959}
1960
1961// activeGnomeForAddr resolves addr's AWAKE gnome, if any. This is the one and only lookup
1962// SaveDay and ChooseType use -- there is deliberately no "the gnome for this address" any more.
1963func activeGnomeForAddr(addr address) (tid string, g *gnome, found bool) {
1964	tidStr, ok := activeToken.Get(addr.String()).(string)
1965	if !ok || tidStr == "" {
1966		return "", nil, false
1967	}
1968	gg, ok := gnomeByTokenID(tidStr)
1969	if !ok {
1970		return "", nil, false
1971	}
1972	return tidStr, gg, true
1973}
1974
1975func ownerOfToken(tokenID string) (owner address, found bool) {
1976	if !gnomes.Has(tokenID) {
1977		return address(""), false
1978	}
1979	o, err := token.OwnerOf(grc721.TokenID(tokenID))
1980	if err != nil {
1981		return address(""), false
1982	}
1983	return o, true
1984}
1985
1986// addOwnerToken / removeOwnerToken: the hold-many index. ownerTokens stores a *[]string per
1987// owner so repeated appends mutate one allocation rather than replacing the tree entry every
1988// time; removal does a linear scan, which is fine at this realm's permanent, tiny upper bound
1989// (batchCap = 24 tokens that will ever exist, minus the one permanently unreachable genesis
1990// gnome, so no owner's slice is ever longer than 24 entries for the whole life of this realm).
1991func addOwnerToken(addr address, tokenID string) {
1992	v := ownerTokens.Get(addr.String())
1993	list, ok := v.(*[]string)
1994	if !ok || list == nil {
1995		fresh := []string{tokenID}
1996		ownerTokens.Set(addr.String(), &fresh)
1997		return
1998	}
1999	*list = append(*list, tokenID)
2000}
2001
2002func removeOwnerToken(addr address, tokenID string) {
2003	v := ownerTokens.Get(addr.String())
2004	list, ok := v.(*[]string)
2005	if !ok || list == nil {
2006		return
2007	}
2008	out := make([]string, 0, len(*list))
2009	for _, id := range *list {
2010		if id != tokenID {
2011			out = append(out, id)
2012		}
2013	}
2014	*list = out
2015}
2016
2017func balanceOf(addr address) int64 {
2018	v := balances.Get(addr.String())
2019	n, ok := v.(int64)
2020	if !ok {
2021		return 0
2022	}
2023	return n
2024}
2025
2026func setBalance(addr address, v int64) {
2027	if v < 0 {
2028		panic("garden: acorn balance cannot go negative")
2029	}
2030	balances.Set(addr.String(), v)
2031}
2032
2033func grantStartingPurseIfNew(addr address) {
2034	if !balances.Has(addr.String()) {
2035		balances.Set(addr.String(), acornsStart)
2036	}
2037}
2038
2039func feeFor(priceUgnot int64) int64 {
2040	return fee.floorUgnot + priceUgnot*fee.rateBps/10000
2041}
2042
2043func exactUgnot(send chain.Coins, want int64) bool {
2044	if len(send) != 1 {
2045		return false
2046	}
2047	return send[0].Denom == ugnotDenom && send[0].Amount == want
2048}
2049
2050func coinsString(c chain.Coins) string {
2051	if len(c) == 0 {
2052		return "nothing"
2053	}
2054	out := ""
2055	for i, coin := range c {
2056		if i > 0 {
2057			out += ","
2058		}
2059		out += strconv.FormatInt(coin.Amount, 10) + coin.Denom
2060	}
2061	return out
2062}
2063
2064func listingFor(tokenID string) (*listing, bool) {
2065	v := listings.Get(tokenID)
2066	l, ok := v.(*listing)
2067	if !ok || l == nil {
2068		return nil, false
2069	}
2070	if l.priceUgnot < 0 {
2071		return nil, false
2072	}
2073	return l, true
2074}
2075
2076// lowerASCII: v4's own helper, unchanged -- ASCII-only on purpose (see v4's own comment on why
2077// not strings.ToLower).
2078func lowerASCII(s string) string {
2079	b := []byte(s)
2080	for i, c := range b {
2081		if c >= 'A' && c <= 'Z' {
2082			b[i] = c + ('a' - 'A')
2083		}
2084	}
2085	return string(b)
2086}
2087
2088func anyPlotPlanted(g *gnome) bool {
2089	for i := 0; i < numPlots; i++ {
2090		if g.plots[i].plantedAt != 0 {
2091			return true
2092		}
2093	}
2094	return false
2095}
2096
2097func eyesCount(id int64) int64 {
2098	v := eyesCounts.Get(strconv.FormatInt(id, 10))
2099	n, ok := v.(int64)
2100	if !ok {
2101		return 0
2102	}
2103	return n
2104}
2105
2106func noseCount(id int64) int64 {
2107	v := noseCounts.Get(strconv.FormatInt(id, 10))
2108	n, ok := v.(int64)
2109	if !ok {
2110		return 0
2111	}
2112	return n
2113}
2114
2115func hatCount(hat string) int64 {
2116	v := hatCounts.Get(hat)
2117	n, ok := v.(int64)
2118	if !ok {
2119		return 0
2120	}
2121	return n
2122}
2123
2124func coatCount(coat string) int64 {
2125	v := coatCounts.Get(coat)
2126	n, ok := v.(int64)
2127	if !ok {
2128		return 0
2129	}
2130	return n
2131}
2132
2133// ---- parsing, meters, mood, level: v4's own proven implementations, byte-for-byte, with
2134// rule() lookups replaced by this file's fixed constants (see the package doc: no SetRule here).
2135
2136func parseActions(raw string) []saveAction {
2137	if raw == "" {
2138		return nil
2139	}
2140
2141	count := 1
2142	for i := 0; i < len(raw); i++ {
2143		if raw[i] == ';' {
2144			count++
2145		}
2146	}
2147	if count > maxActionsPerCall {
2148		panic("garden: too many actions in one call; the limit is " + strconv.Itoa(maxActionsPerCall))
2149	}
2150
2151	parts := strings.Split(raw, ";")
2152	actions := make([]saveAction, 0, len(parts))
2153	for _, part := range parts {
2154		if part == "" {
2155			panic("garden: empty action entry")
2156		}
2157		fields := strings.Split(part, ":")
2158		switch fields[0] {
2159		case actionPat, actionFeed, actionTinker:
2160			if len(fields) != 2 {
2161				panic("garden: malformed action: " + quoteAction(part))
2162			}
2163			actions = append(actions, saveAction{kind: fields[0], at: parseUnixField(fields[1], part)})
2164		case actionPlant, actionHarvest:
2165			if len(fields) != 3 {
2166				panic("garden: malformed action: " + quoteAction(part))
2167			}
2168			actions = append(actions, saveAction{
2169				kind: fields[0],
2170				plot: parsePlotField(fields[1], part),
2171				at:   parseUnixField(fields[2], part),
2172			})
2173		default:
2174			panic("garden: unknown action kind: " + quoteAction(part))
2175		}
2176	}
2177	return actions
2178}
2179
2180func quoteAction(part string) string {
2181	return "\"" + part + "\""
2182}
2183
2184func parseUnixField(s, part string) int64 {
2185	n, err := strconv.ParseInt(s, 10, 64)
2186	if err != nil || n < 0 {
2187		panic("garden: malformed timestamp in action: " + quoteAction(part))
2188	}
2189	return n
2190}
2191
2192func parsePlotField(s, part string) int {
2193	n, err := strconv.Atoi(s)
2194	if err != nil || n < 0 || n >= numPlots {
2195		panic("garden: malformed plot index in action: " + quoteAction(part))
2196	}
2197	return n
2198}
2199
2200func dayNumber(unixSeconds int64) int64 { return unixSeconds / daySeconds }
2201
2202// recordCaredDay reports, via banked, whether it actually incremented g.caredDayCount -- false
2203// on the no-op branch (day == g.lastCaredDay already, e.g. a second SaveDay call the same UTC
2204// day, or a token bought mid-day that the SELLER had already banked that exact day for before
2205// the sale). SaveDay's own caller-scoped gate (lastCaredDayForOwner) depends on this: it must
2206// only spend the owner's one-bank-per-day allowance on a call that actually banked something,
2207// never on a no-op -- a gno-auditor re-audit found that an unconditional advance let a buyer's
2208// SaveDay on a token the seller had already banked that day silently consume the buyer's own
2209// allowance for the rest of that day, denying an otherwise fully legitimate cared day on a
2210// DIFFERENT, genuinely active token later the same day. See the call site in SaveDay.
2211func recordCaredDay(g *gnome, day int64) (banked bool) {
2212	if g.lastCaredDay == 0 {
2213		g.lastCaredDay = day
2214		g.caredMask = 1
2215		g.caredDayCount = 1
2216		return true
2217	}
2218	if day == g.lastCaredDay {
2219		return false
2220	}
2221	shift := uint(day - g.lastCaredDay)
2222	if shift >= caredWindowDays {
2223		g.caredMask = 1
2224	} else {
2225		g.caredMask = (g.caredMask << shift) | 1
2226	}
2227	g.lastCaredDay = day
2228	g.caredDayCount++
2229	return true
2230}
2231
2232func caredDaysCsv(g *gnome) string {
2233	if g.lastCaredDay == 0 {
2234		return ""
2235	}
2236	days := make([]string, 0, caredWindowDays)
2237	for i := uint(0); i < caredWindowDays; i++ {
2238		if g.caredMask&(1<<i) != 0 {
2239			days = append(days, strconv.FormatInt(g.lastCaredDay-int64(i), 10))
2240		}
2241	}
2242	return strings.Join(days, ",")
2243}
2244
2245func meterNow(value, setAt, now, dropEveryHours int64) int64 {
2246	if dropEveryHours <= 0 {
2247		return value
2248	}
2249	elapsedHours := (now - setAt) / hourSeconds
2250	if elapsedHours <= 0 {
2251		return clampMeter(value)
2252	}
2253	drops := elapsedHours / dropEveryHours
2254	return clampMeter(value - drops)
2255}
2256
2257func clampMeter(v int64) int64 {
2258	if v < 0 {
2259		return 0
2260	}
2261	if v > meterMax {
2262		return meterMax
2263	}
2264	return v
2265}
2266
2267func canPat(lastPatAt, now, cooldownHours int64) bool {
2268	if lastPatAt == 0 {
2269		return true
2270	}
2271	return now-lastPatAt >= cooldownHours*hourSeconds
2272}
2273
2274func moodNow(belly, lastPatAt, lastHappyEventAt, adoptedAt, now, hungryThreshold int64) string {
2275	if belly <= hungryThreshold {
2276		return moodHungry
2277	}
2278	if lastHappyEventAt == 0 {
2279		if now-adoptedAt <= happyWindowHours*hourSeconds {
2280			return moodContent
2281		}
2282		return moodSulky
2283	}
2284	if now-lastHappyEventAt <= happyWindowHours*hourSeconds {
2285		return moodHappy
2286	}
2287	if now-lastHappyEventAt >= sulkyAfterHours*hourSeconds {
2288		return moodSulky
2289	}
2290	return moodContent
2291}
2292
2293func xpToNext(level int64) int64 { return levelBase + levelStep*level }
2294
2295func totalXpForLevel(level int64) int64 {
2296	if level <= 1 {
2297		return 0
2298	}
2299	total := int64(0)
2300	for l := int64(1); l < level; l++ {
2301		total += xpToNext(l)
2302	}
2303	return total
2304}
2305
2306func levelFromXp(totalXp int64) int64 {
2307	level := int64(1)
2308	for level < maxLevel {
2309		need := totalXpForLevel(level + 1)
2310		if totalXp < need {
2311			break
2312		}
2313		level++
2314	}
2315	return level
2316}