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