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