saleFeeRecipient
untyped string// gnotchi-opponent, owner's choice
Value
"g1ylpe9juk8cafgf9rt09wzuz85ug2lyfk5metyv"
saleFeeRateBps
int64// 5% of the declared price
Value
500
saleFeeFloorUgnot
int64// 1 GNOT flat minimum per sale
Value
1000000
collectionName
untyped stringValue
"Gnotchi gnomes"
collectionSymbol
untyped stringValue
"GNOME"
adminAddress
untyped stringValue
"g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar"
maxNameLen
untyped bigintValue
(24 <untyped> bigint)
hourSeconds
int64Value
3600
daySeconds
int64Value
86400
numPlots
untyped bigint// acorns.plots -- see DESIGN.md 4.4 for why this can never be tunable
Value
(3 <untyped> bigint)
plantCost
int64// acorns.plantCost
Value
1
feedCost
int64// acorns.feedCost
Value
1
acornsStart
int64// acorns.start
Value
10
meterMax
int64// meters.max
Value
10
patCheer
int64// meters.patCheer
Value
3
hungryAtOrBelow
int64// meters.hungryAtOrBelow
Value
2
happyBonusPct
int64// xp.happyBonus \* 100
Value
10
happyWindowHours
int64// time.happyWindowHours
Value
2
sulkyAfterHours
int64// time.sulkyAfterHours
Value
72
maxLevel
int64// xp.maxLevel
Value
20
typeAtLevel
int64// xp.typeAtLevel -- when ChooseType unlocks
Value
5
xpPat
int64// xp.pat
Value
5
xpFeed
int64// xp.feed
Value
5
xpHarvestPerPlot
int64// xp.harvestPerPlot
Value
10
xpTinkerTask
int64// xp.tinker
Value
15
maxActionsPerCall
untyped bigintValue
(32 <untyped> bigint)
maxBackfillHours
int64Value
48
caredWindowDays
uintValue
64
maxGnomesPageSize
untyped bigintValue
(100 <untyped> bigint)
ugnotDenom
untyped stringValue
"ugnot"
maxPriceUgnot
int64Value
1000000000000000
maxFeeRateBps
int64Value
10000
maxListingDays
int64Value
30
maxListingsPageSize
untyped bigintValue
(100 <untyped> bigint)
moodHungry
untyped stringValue
"hungry"
moodSulky
untyped stringValue
"sulky"
moodHappy
untyped stringValue
"happy"
moodContent
untyped stringValue
"content"
actionPat
untyped stringValue
"pat"
actionFeed
untyped stringValue
"feed"
actionPlant
untyped stringValue
"plant"
actionHarvest
untyped stringValue
"harvest"
actionTinker
untyped stringValue
"tinker"
typeGardener
untyped stringValue
"gardener"
typeExplorer
untyped stringValue
"explorer"
typeTinkerer
untyped stringValue
"tinkerer"
keyGrowHours
untyped stringValue
"growHours"
keyHarvestPerPlot
untyped stringValue
"harvestPerPlot"
keyFeedBelly
untyped stringValue
"feedBelly"
keyBellyDrop
untyped stringValue
"bellyDropEveryHours"
keyCheerDrop
untyped stringValue
"cheerDropEveryHours"
keyPatCooldown
untyped stringValue
"patCooldownHours"
keyTinkerCost
untyped stringValue
"tinkerCost"
keyDailyCareCap
untyped stringValue
"dailyCareCap"
keyLevelBase
untyped stringValue
"levelBase"
keyLevelStep
untyped stringValue
"levelStep"
keySulkyPenaltyPct
untyped stringValue
"sulkyPenaltyPct"
kindMin
untyped string// lower is kinder, so the default sits at max and may only fall
Value
"min"
kindMax
untyped string// higher is kinder, so the default sits at min and may only rise
Value
"max"
validHats
map[string]boolvalidHats/validCoats: unchanged from v1/v2. DESIGN.md section 7.3 records why appearance is still two validated fields and not the "open-ended list of parts" ECONOMY.md asks for: on an immutable realm an open-ended list is either validated against a compiled-in allowlist (exactly as closed as this) or it accepts arbitrary strings, which would undo validNameRune's three audit rounds one field over, since Render echoes into markdown.
- OID
- 02447c…7f75:4
validHats details
validCoats
map[string]bool- OID
- 02447c…7f75:6
validCoats details
validTypes
map[string]boolvalidTypes: the three callings ChooseType accepts. All three are reachable in this realm, which is the point -- v2's assignType was called with explorer XP hard-coded to 0 (garden/v2/garden.gno:823), so Explorer was permanently unreachable there, and for a connected player who never saw a garden or a tinker bench none of the three was. See DESIGN.md section 3.4.
- OID
- 02447c…7f75:8
validTypes details
ruleBand
typeruleBand is one tunable number: its key, its deploy default, and the closed interval SetRule will accept. kindEnd records which end of that interval is kinder to the player, and is what TestEveryBandIsPlayerSafe checks the default against.
Value
v3.ruleBand
ruleBands
[]v3.ruleBandruleBands is the whole tunable surface of this realm. Nothing outside this table can be changed after deployment, by anyone, ever. WHY THIS TABLE EXISTS AT ALL, since no other realm in this project has one. DECISIONS.md records that the build brief gave garden and arena a Pause(), that an AI instruction overrode it, that consequently "no realm has an admin, a pause or a settable parameter, and every number is a frozen constant", and that "whether the next realms get an owner-settable config is an open decision for the owner, and it is cheap today". Meanwhile LOOP.md:89-92 already specifies NINE of the numbers below changing after the first ten testers answer, and LOOP.md:127-130 says to deploy nothing before they do. A v3 that froze them would be obsolete on the day it shipped: that rebalance would cost a v4 plus a second migration. So the numbers LOOP.md names are tunable, inside bands, in one direction only, and FreezeRules below makes the frozen outcome reachable too. WHY THE RISK IS SMALL, RESTATED NOW THAT GNOMES HAVE A PRICE. An earlier version of this paragraph said "this realm holds nothing of value"; with a sale function that is no longer the whole truth, so here is the exact truth instead. The realm holds no coins between transactions: Buy's banker is BankerTypeOriginSend, which can move at most the coins the buyer attached to that one call and dies with the call, and there is no RealmSend banker anywhere in this file. Gnomes DO now have a price, and the admin reaches none of them: no function here lets adminAddress list, cancel, buy, move, burn or re-price a gnome, change the fee, change the recipient, or pause sales. The only state the admin can reach is ruleValues, through SetRule, inside the bands, one way. There is still no theft available to a compromised admin; what it can do is rebalance the game toward the player. THE SAFETY PROPERTY, STATED EXACTLY, BECAUSE AN EARLIER AND LOOSER VERSION OF THIS COMMENT WAS FALSE AND SHIPPED NOWHERE ONLY BECAUSE AN AUDIT CAUGHT IT. Two parts, and the second part is the one that was missing: 1. Every band is one-directional: kindEnd records which end is kinder to the player, and each default sits at the HOSTILE end, so the only place a value can go is toward the player. TestEveryBandIsPlayerSafe pins that, per key. 2. SetRule RATCHETS: it rejects any value less kind than the CURRENT one, not merely any value outside the band. Without this, a band being a closed interval meant it was freely re-enterable from either side -- levelBase 50 -> 30 -> 50 was permitted, and because levelFromXp is recomputed and written back on every SaveDay, that last call made STORED LEVELS DROP on gnomes that already existed. See SetRule's own comment for why that specific power was the one thing a redeploy could never grant, and for the price the ratchet charges (there are no take-backs). Together: for this realm's whole life, a level can never go down, a meter can never drain faster, income can never fall, a cost can never rise and a gate can never tighten. That is an auditable property of this table plus that check, not a promise about anybody's intentions. TestTuningIsAOneWayRatchetTowardThePlayer exercises both directions of every key, which is the test the earlier version of this comment did not have. THE ONE RESIDUAL, NAMED RATHER THAN CLAIMED AWAY. moodNow is first-match-wins (hungry, sulky, happy, content) and only happy and sulky carry an XP modifier, so BEING HUNGRY SHIELDS A NEGLECTED GNOME FROM THE SULKY PENALTY. feedBelly and bellyDropEveryHours both raise effective belly, so either can lift a gnome with belly 1-2, unpatted for 72h, out of hungry and into sulky -- costing it sulkyPenaltyPct of that one day's capped care XP. It is bounded at 10% of at most 60 XP, it only touches a gnome already being neglected for three days, and TestRaisingFeedBellyCanCostANeglectedGnomeTenPercent demonstrates it rather than denying it. Both keys stay tunable because they are the core of LOOP.md's rebalance and the kindest changes available; hungryAtOrBelow, which was the largest contributor and is on nobody's list, was removed from this table instead. THE OWNER CAN CLOSE THE RESIDUAL ENTIRELY IN ONE CALL: SetRule(sulkyPenaltyPct, 0) is permitted by the ratchet and makes the flip cost exactly nothing. The perverse incentive underneath it (a gnome at belly 2 out- earning the same gnome at belly 3) exists at the DEFAULT config too and is a faithful port of app/js/rules.js's own ladder -- correcting it here would create precisely the chain/browser mood divergence this realm exists to stop, so it is ported, not fixed.
- OID
- 02447c…7f75:10
ruleBands details
plot
typeplot is one of this gnome's numPlots garden plots. TWO DELIBERATE DIFFERENCES FROM v2's plotState (garden/v2/garden.gno:164-167): 1. No \`growing bool\`. plantedAt == 0 IS the empty state. v2 carried both and had to keep them consistent by hand in four places; one field cannot disagree with itself. 2. readyAt is STORED, not computed. This is the only derived value persisted anywhere in this realm and it is deliberate: growHours is tunable, so a readiness check against TODAY's growHours would silently change the ready time of a plot already in the ground. Freezing it at plant time means a rule change can never reach backwards into a plant a player has already made. 8 bytes per plot to make a tunable parameter safe.
Value
v3.plot
gnome
typegnome is the whole persisted pet. EVERY FIELD HERE HAS AN EXPORTED READER -- see the package doc's one rule, and the table in DESIGN.md section 2.4 mapping field to reader. NOT here, on purpose: acorns. They live in \`balances\`, keyed by owner address, because ECONOMY.md:65-67 records the owner finding that hole himself -- "Acorns never travel with a gnome when it is sold. They belong to the player. Without this, money buys a gnome and inherits its acorns ... it is the kind of thing that is unfixable after deployment." Gnomes sell here, so this is no longer a theoretical distinction: Buy moves the gnome and no acorn moves to the buyer, because the purse was never on the gnome to begin with. The one acorn write Buy makes is TO THE SELLER -- it settles the garden's planted plots into the seller's own purse, because a planted plot is a claim on acorns and would otherwise have travelled. Meters are stored as (last-known value, the unix second it was set) per the brief, and meterNow works the current value out from elapsed time whenever anything reads it. Nothing ticks on this realm's clock, there is no scheduled job, and nothing here is written by anything other than a call the owning address itself signed.
Value
v3.gnome
listing
typelisting is one gnome offered for sale. Keyed by tokenID in \`listings\`; at most one per gnome, and List replaces rather than stacks. buyer is the zero address for "anyone may buy", or one named address. A named buyer is the ONLY gift-shaped path in this realm, and it still pays the floor: see Buy.
Value
v3.listing
feeConfig
typefeeConfig is the three sale constants, as init captured them. See the top of the file.
Value
v3.feeConfig
token
*v0.Token- OID
- 02447c…7f75:114
token details
ledger
*v0.PrivateLedger- OID
- 02447c…7f75:116
ledger details
nextID
v0.IDValue
<zero>
gnomes
*v0.Tree// tokenID -> \*gnome
- OID
- 02447c…7f75:26
gnomes details
ownerIndex
*v0.Tree// owner address -> tokenID
- OID
- 02447c…7f75:29
ownerIndex details
balances
*v0.Tree// owner address -> int64 acorns (per PLAYER, not per gnome)
- OID
- 02447c…7f75:32
balances details
ruleValues
*v0.Tree// rule key -> int64
- OID
- 02447c…7f75:35
ruleValues details
listings
*v0.Tree// tokenID -> \*listing
- OID
- 02447c…7f75:38
listings details
rulesFrozen
boolValue
false
fee
v3.feeConfig- OID
- 02447c…7f75:113
fee details
testFeeOverride
*v3.feeConfigValue
<zero>
transferGate
stringValue
""
init.80
func()- OID
- 02447c…7f75:45
init.80 details
assertFeeConfig
func(cfg v3.feeConfig, realmAddr .uverse.address)assertFeeConfig is init's gate on the three sale constants, split out so a test can prove what it refuses, and prove that the committed constants pass it, without a failing init taking the whole test package down. Every message names the constant to edit. realmAddr is this realm's own address: fees sent there would be burned, because nothing here can ever withdraw them.
- OID
- 02447c…7f75:47
assertFeeConfig details
saleGateExtension
typesaleGateExtension replaces v1/v2's soulboundExtension. v2 panicked on EVERY transfer; this panics on every transfer EXCEPT the one Buy is performing right now, identified by transferGate holding that exact token id. The grc721 PrivateLedger is never exported and no Approve, SetApprovalForAll or TransferFrom is exposed by this realm, so Buy is the only code that can reach the ledger at all -- the gate is the second lock on a door that already has one, and it is the lock whose claim ships on chain where anyone can read it. See the package doc for why this relies on the transaction boundary rather than on hook ordering: the hook fires AFTER the ledger write, so a refused transfer's write is discarded only because the uncaught panic aborts the whole transaction. No recover(), ever.
Value
v3.saleGateExtension
Adopt
func(name string, hat string, coat string)Adopt mints one gnome to the caller. Every validation v1 and v2 performed is carried over in the same order with the same messages -- the name charset allowlist, the invisible-letter denylist, the one-gnome-per-address rule, the OriginSend refusal -- because those checks are the output of three audit rounds and re-deriving them from memory is how they get lost. TWO THINGS CHANGE BECAUSE GNOMES CAN NOW BE SOLD. First, an address that has sold its gnome has no gnome, so it may adopt again -- "one gnome per address" is a statement about the present, not a lifetime count, and adoption is free for a fresh address anyway, so refusing it to a seller would punish only the honest. Second, the starting purse is granted ONCE PER ADDRESS, EVER: only if this address has never held acorns. A seller keeps their purse (acorns belong to the player, ECONOMY.md:65-67), so a re-adopting seller must not be topped back up to acornsStart, and a buyer who already has a purse from an earlier gnome keeps that one. Without this, sell-and-readopt would refill acorns for free. THE CUSTODY QUESTION IS CLOSED: ONLY A WALLET MAY ADOPT. realms/garden/NOTES.md recorded it in full and reserved it for the owner: cur.Previous().Address() is not restricted to a human wallet, so a realm that called Adopt would become the permanent, unrecoverable owner of a gnome, with no burn, admin or transfer to undo it. v1 and v2 left that open. On 2026-10-11 the owner chose the accident case over the sponsor case -- an orphaned gnome permanently owned by some batching or multicall realm is a real person's lost gnome, and it can never be undone -- so Adopt now refuses any caller that is not a direct wallet call, with the same IsUserCall gate Buy already uses (DESIGN.md section 7.5). What the gate is, exactly, read from this toolchain's chain/runtime: IsUserCall() is "pkgPath == """, true for a \`gnokey maketx call\` (or a wallet's MsgCall) straight from a user and false for every code realm, INCLUDING the ephemeral realm a \`gnokey maketx run\` script executes in (that one is IsUserRun, not IsUserCall). So a sponsor realm, a batching realm and a run script are all refused, and none of them can ever be let back in on this realm: a v4 with an explicit "adopt for" path is how that door would reopen. The refusal comes first, before anything is read or written, so a refused caller gets this reason and not a later one. A consequence worth stating: since Buy is also wallet-only, NO REALM CAN EVER OWN A GNOME here, by construction. The audit's G-2 worry -- a realm owning and listing a gnome so that Buy pays a price to an address with no banker, where it is burned -- has no remaining path.
- OID
- 02447c…7f75:48
Adopt details
alreadyAdopted
func(addr .uverse.address) boolalreadyAdopted: unchanged from v1/v2, and now also Buy's "one gnome per address" check. Both ownerIndex and token.BalanceOf are consulted, not either alone, so a disagreement between this realm's own index and the grc721 ledger can never be used to mint or buy a second gnome to one address IN THIS REALM. Buy keeps the two in step by writing ownerIndex in the same call as the ledger move. "ONE GNOME PER ADDRESS" IS TRUE PER REALM AND FALSE GLOBALLY, AND THAT IS PERMANENT. This paragraph is here rather than only in DESIGN.md because DESIGN.md is a repo file: editable, not deployed, and not available to whoever reads this realm's source off chain with \`vm/qfile\`. The caveat that most needs to reach a future realm's author was otherwise written in the one place that does not ship. garden/v2 is deployed and immutable at gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/garden/v2. It keeps its OWN ownerIndex and its OWN grc721 ledger, neither of which this function can see, and it stays fully callable forever -- there is no pause and no admin anywhere in it. So one address can hold a v2 gnome AND a v3 gnome simultaneously, with different names, hats, levels, callings and acorn balances, and v2's own SaveDay keeps accruing on the v2 one indefinitely. Nothing on chain reconciles the two and nothing ever can. The consequence that matters most to a future author: the deployed arena/v1 imports garden/v2 by absolute path, immutably, and reads GnomeByAddress live at battle-resolution time. For a dual adopter it therefore authoritatively reads the V2 gnome -- the state the app has stopped showing and this realm can never update. ChooseType here cannot touch it. ANY FUTURE REALM THAT ASSUMES A GLOBAL ONE-GNOME-PER-ADDRESS INVARIANT IS BUILDING ON A FALSE PREMISE. An arena/v2 written against v3 must pick one realm as the identity of record and say which. v3 deliberately does not import v2 to close this. See DESIGN.md section 6.3 for the full argument; the part of it that does not expire is that a hard import would pin this realm to one path on one chain and make it undeployable anywhere v2 is not -- the exact coupling arena/v1's own gnomod.toml has to warn its deployers about.
- OID
- 02447c…7f75:49
alreadyAdopted details
saveAction
typesaveAction is one parsed entry from a SaveDay call's action list.
Value
v3.saveAction
SaveDay
func(actionsRaw string)SaveDay is the brief's "one daily save": the day's pats, feeds, plants, harvests and tinker task submitted as a single list in one signed call. THE WIRE FORMAT IS UNCHANGED FROM v2, deliberately. It is proven on chain (TESTNET.md:51-60, heights 310579/310601/310681) and app/js/live.js already builds it, so changing it would cost app work and buy nothing. Semicolon-separated entries, each colon-separated: "pat:<unixSeconds>" "feed:<unixSeconds>" "tinker:<unixSeconds>" "plant:<plotIndex>:<unixSeconds>" "harvest:<plotIndex>:<unixSeconds>" e.g. "pat:1791600000;feed:1791601000;plant:0:1791602000". An empty string is a legal no-op. A list, rather than a \[]Action parameter, because gno.land's own call-argument decoder (gno.land/pkg/sdk/vm/convert.go, convertArgToGno) can only build primitive scalars and \[]byte from a transaction -- there is no case there for a slice of structs, and no Gno-side type cleverness changes what the wire format can carry. No gnomeID parameter either: one gnome per address, no delegation, so there is no second gnome a caller could mean and no path by which this could legitimately act on anyone else's. A sale changes which address that is, and nothing else here: the seller's next SaveDay finds no gnome, and the buyer's must date every action after the sale (Buy resets lastActionAt). TIMESTAMPS ARE BLOCK TIME, NOT WALL CLOCK, AND A CLIENT THAT FORGETS THAT FAILS EVERY TIME. DECISIONS.md:500-515: the first on-chain SaveDay failed with "action timestamp cannot be in the future" because the client sent \`date -u +%s\` while block time ran ~3 seconds behind wall clock, measured not assumed, and "a browser sending Date.now() will therefore fail essentially always". The fix is client-side -- read the chain's own latest block time and subtract a small margin. This realm deliberately does NOT add a future tolerance: DECISIONS' own warning is that a tolerance near midnight would let a caller claim the next UTC day's XP allowance early. THREAT MODEL. actionsRaw is client-supplied and this realm is the only authority over it. Carried over from v2 whole, including both of its audit fixes: - Replay, in whole or in part: every timestamp must be strictly greater than lastActionAt, the gnome's own high-water mark. A replayed batch's first action is by definition \<= that mark. - Duplicate actions inside one call: the same strict-increase rule applies action to action, so two actions sharing a timestamp are rejected whatever kinds they are. - Future-dated actions: every timestamp must be \<= this call's own block time. - Actions at or before adoption: subsumed by the replay check, since lastActionAt starts at adoptedAt. - Unlimited backfill after a long dormancy (v2's audit fix F2, an owner ruling): maxBackfillHours closes this twice over. Any action older than the window is rejected outright with a message a player can understand, AND the floor the replay check enforces is itself clamped forward to now-maxBackfillHours before the loop runs, so the bound does not rest on a single comparison a future edit could weaken. - Inflated action counts: pat spacing bounds pats per day arithmetically regardless of what the list claims (two a day at the default 12-hour cooldown, four if the owner ratchets it to the band floor of 6 -- v2's version of this note said "more than two a day is impossible", which was true of v2's fixed constant and is not true here), and feed/plant/tinker are each gated by the caller's own acorn balance, which this exact set of rules is the only thing that can increase. The economy is the rate limit. Even the floor case is far under dailyCareCap, so nothing here is a way around the cap. - A thousand actions in one list: rejected on the delimiter COUNT, before strings.Split allocates anything proportional to the input (v2's audit fix F5). - XP inflation via the daily cap (v2's audit fix F1): the whole call's raw care XP is mood-adjusted, then clamped to whatever remains of dailyCareCap FOR THE UTC CALENDAR DAY, tracked by the gnome's own accumulator rather than anything scoped to one call. ATOMICITY: every check runs against local copies -- \`work\` for the gnome, \`acorns\` for the balance -- taken at the top. Nothing is written back until the whole list has validated and applied cleanly, so any panic before the final commit leaves persisted state completely untouched. A plain value copy, not a recover()-and-rollback; see the package SECURITY note.
- OID
- 02447c…7f75:50
SaveDay details
ChooseType
func(kind string)ChooseType sets this gnome's calling, once, permanently, at level typeAtLevel or above. THIS REPLACES v2's DERIVED TYPE, AND IT IS THE WHOLE FIX FOR "ADVERTISES SOMETHING UNREACHABLE". v2 called assignType(xpGarden, 0, xpTinker) -- a literal 0 for explorer, because it had no Visit call -- so Explorer could never be assigned to any gnome, ever; and because the app hides the garden and the tinker bench from a connected player, such a player reached level 5 with 0 and 0, tied three ways, and got "" instead. Four independent sources already said the answer is a chooser rather than a tally: - app/js/rules.js's own assignType returns {choose: tied} on a tie, documented "Ties: player chooses". - garden/v2/garden.gno's own comment: "the brief says 'the player chooses', and this version has no chooser endpoint". - LOOP.md:99-101: "at level 5 the player CHOOSES the calling rather than having it derived from tallies that can only ever say Gardener." - DECISIONS.md:643-646: the tallies are unbalanced by construction, because gardening funds the gnome's food and tinkering does not, so the derivation was never a choice. WHAT IT COSTS, stated rather than glossed: the type stops being evidence of what a player did and becomes evidence of what they picked. arena/v1's type edge then reflects a choice rather than a history -- strictly better than reflecting a history that could only ever have one answer, but a different claim, and anyone reading that edge should know it changed. One choice, no retrain. rules.config.json has a retrainDays key; this realm implements nothing for it (DESIGN.md section 7.4).
- OID
- 02447c…7f75:51
ChooseType details
List
func(priceUgnot int64, buyer .uverse.address, days int64)List offers the caller's own gnome for sale. priceUgnot may be 0: that is the gift shape, and the buyer still pays the floor. buyer is the zero address ("") for anyone, or one named address other than the seller. days is 1..maxListingDays. Listing again replaces the standing listing; it is how a price is changed, and because Buy demands the exact total of the listing it finds, a buyer who prepared coins for the old price is refused rather than charged the new one. Listing locks nothing. The seller keeps caring for the gnome; care only ever adds to what the buyer gets, and a listed gnome that goes hungry is the seller's own problem to price.
- OID
- 02447c…7f75:52
List details
Cancel
func()Cancel withdraws the caller's own listing. Only the seller can; nobody else -- not the admin, not a reserved buyer -- can cancel or alter anyone's listing.
- OID
- 02447c…7f75:53
Cancel details
Buy
func(tokenID string)Buy purchases the listed gnome tokenID, paying its seller and the fee recipient in the same call. The caller must be a wallet, must not own a gnome, must be the reserved buyer if there is one, and must attach EXACTLY the listing's price + fee in ugnot and nothing else. VALIDATE, THEN COMMIT -- AND THE COMMIT PHASE CANNOT FAIL. Every refusal below happens before the first write. After the "commit" line nothing remains that can panic on chain: the ledger move is to an owner we have just verified with a gate we have just opened; the two coin sends are for amounts we have just checked equal the envelope (so the BankerTypeOriginSend cap cannot refuse them), from this realm's own address (which the envelope has credited before this code runs), to addresses validated at List time and at init. Payment is therefore atomic with transfer by construction, and not only because an uncaught panic would abort the transaction -- though it would, and that is the second lock. NO REENTRANCY IS POSSIBLE AND THIS IS WHY, STATED SO IT CAN BE CHECKED RATHER THAN TRUSTED: the only calls Buy makes outside this file are grc721's TransferFrom (a /p/ package running in this realm's own frame, whose only hook is saleGateExtension above) and banker.SendCoins (a native that credits a bank balance and runs no code at the recipient). Neither can call back into this realm. State is nonetheless fully written before the sends, so even if a future chain gave coin recipients a hook, re-entering Buy for this token would find no listing and a different owner. EXACTLY, NOT AT LEAST. Accepting more than the total would make this realm a bank for the difference, with no withdrawal path; accepting a different denomination would strand it. The abort returns the whole envelope to the buyer, so a wrong amount costs gas and nothing else. Listing() publishes the exact total a wallet must attach.
- OID
- 02447c…7f75:54
Buy details
Listing
func(tokenID string) (seller .uverse.address, priceUgnot int64, buyer .uverse.address, listedAtUnix int64, expiresAtUnix int64, feeUgnot int64, totalUgnot int64, found bool)Listing is one gnome's standing offer, including the EXACT total a wallet must attach to Buy. found is false when the gnome is not listed. An expired listing is still returned, with its expiresAt, so a client can tell "expired" from "never listed"; Buy refuses it.
- OID
- 02447c…7f75:55
Listing details
TotalListings
func() int64TotalListings / ListingsPage: the board. Same shape and same reasoning as TotalGnomes / GnomesPage -- this project has paid for a missing enumerator once already. The page is tokenIDs, oldest key first; resolve each with Listing() and OwnerOfToken(). Expired listings are included until their seller lists again or cancels.
- OID
- 02447c…7f75:56
TotalListings details
ListingsPage
func(offset int, limit int) []string- OID
- 02447c…7f75:57
ListingsPage details
QuoteFee
func(priceUgnot int64) (feeUgnot int64, totalUgnot int64)QuoteFee is what a sale at priceUgnot costs the buyer, before anyone lists anything.
- OID
- 02447c…7f75:58
QuoteFee details
FeeRules
func() stringFeeRules publishes the fee exactly as this realm will charge it, for the same reason Rules() publishes the game numbers: the chain's own value is the one that applies, and a client that can read it has no reason to hold a copy. Everything here is frozen for the realm's life.
- OID
- 02447c…7f75:59
FeeRules details
Provenance
func(addr .uverse.address) (adoptedAtUnix int64, acquiredAtUnix int64, saleCount int64, found bool)Provenance is the reader for the two fields a sale adds to the gnome.
- OID
- 02447c…7f75:60
Provenance details
GnomeByAddress
func(addr .uverse.address) (tokenID string, name string, hat string, coat string, level int64, gnomeType string, adoptedAtUnix int64, found bool)GnomeByAddress has the BYTE-IDENTICAL signature to garden/v2's, on purpose: anything already written against v2 -- arena/v1's own GnomeByAddress call, app/js/adventure- villagers.js, app/js/live.js's qeval tuple parser, which reads these eight values positionally -- repoints at this realm with a one-line import change and no rewrite. That matters because arena/v1 is deployed and imports garden/v2 immutably, so a gnome adopted here cannot use arena/v1 at all and battle needs an arena/v2. Keeping this signature stable is the one thing this realm can do to make that a one-line change rather than a port. See DESIGN.md section 6.3.
- OID
- 02447c…7f75:61
GnomeByAddress details
GardenState
func(addr .uverse.address) (plantedAtCsv string, readyAtCsv string, readyCount int64, found bool)GardenState is the read garden/v2 does not have, and the reason this realm exists. plantedAtCsv and readyAtCsv are each numPlots comma-separated unix seconds in plot order, with 0 for an empty plot -- e.g. "1791600000,0,0" / "1791686400,0,0". Two parallel fixed-width lists rather than one encoded blob, because the app's qeval reader already splits plain strings and because a reader that has to parse a schema is a reader that can parse it wrongly. readyCount is how many plots are harvestable AT THIS MOMENT, computed by the realm against each plot's own stored readyAt. The caller could derive it, but only by also knowing the chain's current block time, which is exactly the thing a browser gets wrong (see SaveDay's comment on DECISIONS.md:500-515). So the chain answers it.
- OID
- 02447c…7f75:62
GardenState details
CareState
func(addr .uverse.address) (belly int64, cheer int64, acorns int64, totalXp int64, lastPatAtUnix int64, lastHappyEventAtUnix int64, lastActionAtUnix int64, found bool)CareState is v2's CareState plus the two things it left out: lastHappyEventAt (the mood input v2 stored at its own line 197 and exposed nowhere, so the app substituted lastActionAt, which is a different number -- a harvest-only day advances lastActionAt and not lastHappyEventAt), and acorns now read from the player's balance rather than the gnome. belly and cheer are returned already decayed to time.Now(), as v2's were. MeterState below returns the raw stored pair for a caller that wants to run app/js/rules.js's own meterNow against this realm's state and check the two agree. The signature is NOT positionally compatible with v2's CareState, unlike GnomeByAddress above. Nothing cross-realm reads CareState -- only the app does -- and inserting lastHappyEventAt where it belongs was worth more than preserving a tuple shape for a single caller that has to change anyway.
- OID
- 02447c…7f75:63
CareState details
MeterState
func(addr .uverse.address) (belly int64, bellyAtUnix int64, cheer int64, cheerAtUnix int64, found bool)MeterState returns the RAW stored meter pairs, undecayed. v2 exposed only the decayed values, which is lossy in a way that matters here: a caller cannot check the chain's decay against app/js/rules.js's meterNow if it is only ever handed the chain's answer. With this, the browser can run its own formula over the chain's own inputs -- which is how a repeat of the mood divergence in DESIGN.md section 1.5 gets caught in a day instead of in a year.
- OID
- 02447c…7f75:64
MeterState details
Progress
func(addr .uverse.address) (xpGarden int64, xpTinker int64, harvestCount int64, lastTinkerAtUnix int64, caredDayCount int64, dailyCareXpSpent int64, dailyCareDay int64, found bool)Progress is the rest of the stored state: the lifetime counters, the tinker clock and this UTC day's care-XP spend. Every one of these is a field garden/v2 writes and cannot read back. dailyCareXpSpent is the one a client most needs and v2 most lacked: the chain caps care XP per UTC DAY and app/js/rules.js caps it per CALL, so a browser that does not subtract this will promise XP the chain then refuses. dailyCareDay says which UTC day that spend belongs to, so a client can tell "60 spent today" from "60 spent on a day that has since rolled over".
- OID
- 02447c…7f75:65
Progress details
CaredDays
func(addr .uverse.address) (daysCsv string, found bool)CaredDays returns the UTC day numbers this address saved care on, newest first, comma separated -- e.g. "20371,20370,20368". At most caredWindowDays of them; empty if never. THE STREAK RULE IS DELIBERATELY NOT HERE. app/js/rules.js's streakFrom is the single tested implementation of it and takes exactly this list (as "YYYY-MM-DD", one line of JS from a day number -- rules.js:233 already does that conversion). Porting its ISO-week rest-day attribution onto an immutable contract would mean two implementations of one rule, and getting it subtly wrong would make the chain and the browser disagree about the streak forever. The chain stores facts; the browser owns the rule. DESIGN.md section 2.3. The encoding (a uint64 mask relative to lastCaredDay) is internal and no caller needs to know it; TestCaredMaskRoundTrips pins it.
- OID
- 02447c…7f75:66
CaredDays details
Mood
func(addr .uverse.address) (mood string, found bool)Mood returns the realm's OWN mood for this gnome, right now. This exists because the chain's mood is what applies the XP modifier, and because v2 proves a divergence here is not hypothetical: v2 ported rules.js's mood faithfully, rules.js was then fixed so a never-patted gnome is "new" rather than "neglected", and the deployed realm and the shipped browser have returned different moods for the same gnome ever since, with no way to notice from outside. v3 matches the current rules.js (see moodNow) and publishes its answer so the next divergence is observable.
- OID
- 02447c…7f75:67
Mood details
CollectionInfo
func() (name string, symbol string)CollectionInfo / TotalSupply / BalanceOf / OwnerOfToken: the GRC721 read surface. THESE EXIST BECAUSE AN AUDIT FOUND THE ONE PLACE THIS REALM'S OWN RULE DID NOT HOLD. Every field of the gnome struct has a reader; \`token\` and \`ledger\` did not. They are persisted realm state (the token is created inside garden's own storage, so there is no separate grc721 realm an indexer could query instead), and before these four functions the only read of any of it was token.TotalSupply() interpolated into Render's markdown, where no caller can consume it as a typed value. What that cost, permanently, if it had shipped: no wallet could list the gnome, no indexer could enumerate holders, no other realm could verify ownership except through garden's own GnomeByAddress, and -- the sharpest one -- there was NO tokenID-to-owner reader at all, because ownerIndex is owner-to-tokenID only. A tokenID observed in an Adopt event could not be resolved back to an owner without brute-forcing GnomesPage. garden/v2 has the identical gap and can never close it. nextID is the one piece of persisted state with no direct reader, and that is deliberate rather than missed: it is a seqid counter equal to the number of adoptions (there is no burn, and a sale moves a token without minting one), so TotalSupply() below is the same number by construction. Recorded so the next reader does not have to work that out.
- OID
- 02447c…7f75:68
CollectionInfo details
TotalSupply
func() int64- OID
- 02447c…7f75:69
TotalSupply details
BalanceOf
func(addr .uverse.address) int64BalanceOf is 0 or 1 for every address, forever: one gnome per address, no burn, and Buy refuses a buyer who already holds one. Exposed anyway rather than replaced by a bool, because a GRC721 consumer expects this shape. An invalid address owns nothing, so 0 is the CORRECT answer here and not an error signal -- this function deliberately cannot distinguish "invalid address" from "owns none", because there is nothing to distinguish. Acorns(addr) below is the reader that does draw a distinction (absent versus zero), and it draws it about something where the difference is real. An earlier version of this comment claimed the opposite of the line underneath it; corrected here, before it could ship and say that permanently.
- OID
- 02447c…7f75:70
BalanceOf details
OwnerOfToken
func(tokenID string) (owner .uverse.address, found bool)- OID
- 02447c…7f75:71
OwnerOfToken details
TotalGnomes
func() int64TotalGnomes / GnomesPage: the enumerator garden/v2 has no equivalent of. This project has already paid for a missing enumerator once. DECISIONS.md:780-791 records that arena/v1 "exposes no way to list challenges -- confirmed with vm/qfuncs against the deployed realm, not assumed from the source -- and a realm cannot be edited", so there is no challenge inbox and there cannot be one; "if an inbox is ever wanted, it is an arena/v2 plus a migration". hollowmere/v1 learned that and shipped TotalClaimants/ClaimantsPage. garden/v2 did not, so no leaderboard, no village roster and no "who is playing" query is possible against it at all. Twenty lines, unavailable forever if omitted.
- OID
- 02447c…7f75:72
TotalGnomes details
GnomesPage
func(offset int, limit int) []stringGnomesPage returns up to limit owner addresses in ownerIndex's own stable key order, starting at offset. limit is CLAMPED to maxGnomesPageSize rather than rejected, so a caller asking for too much still gets a gas-bounded page instead of an aborted call; a negative offset or a non-positive limit are caller mistakes with no sane page to return. Paging with offset 0, then offset+len(page), until a page shorter than its requested limit comes back, covers every owner exactly once with no gaps and no duplicates for any snapshot where the tree does not grow between calls. TestGnomesPagePagesEveryOwnerExactlyOnce proves that directly rather than assuming it from avl.Tree's doc comment.
- OID
- 02447c…7f75:73
GnomesPage details
Acorns
func(addr .uverse.address) (acorns int64, found bool)Acorns is the player's own acorn balance, read directly rather than only through CareState, because acorns are a PLAYER's property here and not a gnome's (ECONOMY.md:65-67) and the read should say so. An address that has never held a purse gets 0, false -- never a bare 0 that a caller could mistake for a real empty purse. A player who has SOLD their gnome still has a purse, and this says so: it is keyed on the purse, not on the gnome, because the purse outlives the gnome and is what a later Adopt or Buy will find waiting.
- OID
- 02447c…7f75:74
Acorns details
Rules
func() stringRules publishes every number this realm actually uses, as "key=value;key=value;...", fixed values first in the order of DESIGN.md's agreement table, then the tunables in ruleBands order. THIS IS THE PIECE v2 HAD NO ANSWER TO. v2 kept thirty-odd values hand-copied from app/js/rules.config.json with no reader, so no drift check between the chain and the browser was possible even in principle -- nothing on chain could be asked what growHours was. With this, anyone can: gnoweb, curl, or a node test that compares each key against rules.config.json and fails on any mismatch. AND IT FIXES THE DIRECTION OF CORRECTION, which is the part that matters on an immutable contract. v2's two copies could drift and neither was authoritative. Here the chain is authoritative and, for a tunable, is the only place a value can change -- so a client should READ this and use it, not hold a second copy and hope. WHAT IS NOT DONE, stated rather than implied: the node-side half of that check does not exist. The follow-up is one test comparing this output against rules.config.json, plus making app/js/rules-bridge.js prefer these values for a connected player. Neither is written. Until they are, this is a capability and not a check.
- OID
- 02447c…7f75:75
Rules details
RuleBands
func() stringRuleBands publishes the tunable surface itself: "key=min..max" for every band, in the same order Rules() uses. Without this, "the admin can only tune inside bands" is a claim in a document. With it, anyone can read the actual limits off the chain and check what this realm will and will not accept, without trusting DESIGN.md, this comment, or the owner.
- OID
- 02447c…7f75:76
RuleBands details
AdminState
func() (admin .uverse.address, frozen bool)- OID
- 02447c…7f75:77
AdminState details
SetRule
func(key string, value int64)SetRule changes one tunable number, inside its band, if the rules are not frozen. Only adminAddress. No admin transfer. Nothing here can touch a gnome, a balance, an XP total or a level -- the only state this function can reach is ruleValues, and only for a key that is in ruleBands, and only to a value inside that key's band. See ruleBands' own comment for why this exists at all and why the bands make it defensible; DESIGN.md section 5 for the full argument, including the explicit note that this is the one decision in v3 the owner should actively approve or veto before deploy, and that vetoing it is a deletion rather than a redesign.
- OID
- 02447c…7f75:78
SetRule details
FreezeRules
func()FreezeRules ends tuning permanently. One admin-only, irreversible call; afterwards SetRule panics forever and this realm is exactly as frozen as garden/v2. This is what makes the whole tunable-config decision safe to defer rather than get right before deploy: the owner can deploy tunable, run Check A, apply LOOP.md's numbers and then freeze -- or freeze on day one and never tune. Both outcomes are reachable from this realm. Only one is reachable from v2. There is no unfreeze, on purpose. A reversible freeze is not a freeze.
- OID
- 02447c…7f75:79
FreezeRules details
Render
func(path string) stringRender is the gnoweb page. Unlike v2's, it shows the garden -- which is the whole point of this realm, and a player should be able to see their plots without a client at all. path is bech32-validated before anything derived from it is echoed back into the page, the same guard v1, v2, hollowmere and arena all use, for the same reason: path is whatever a URL put after the realm path, so it is attacker-controlled, and gnoweb renders this return value as markdown. A rejected path is never reflected at all, which is strictly safer than escaping it.
- OID
- 02447c…7f75:80
Render details
gnomeForAddr
func(addr .uverse.address) (tid string, g *v3.gnome, found bool)- OID
- 02447c…7f75:81
gnomeForAddr details
balanceOf
func(addr .uverse.address) int64balanceOf: a missing key is a zero balance, not an error. Only Adopt creates a key, so a missing key means "no gnome here", which every caller has already checked.
- OID
- 02447c…7f75:82
balanceOf details
setBalance
func(addr .uverse.address, v int64)setBalance refuses to store a negative balance. Nothing above should ever produce one -- every spend is guarded by its own "not enough acorns" check -- so this is not a guard that is expected to fire; it is a guard that makes a future arithmetic mistake abort the transaction instead of silently minting a debt the economy has no concept of.
- OID
- 02447c…7f75:83
setBalance details
grantStartingPurseIfNew
func(addr .uverse.address)grantStartingPurseIfNew gives an address acornsStart the FIRST time a gnome arrives in its hands, by Adopt or by Buy, and never again. See Adopt's comment: a seller keeps their purse, so neither re-adopting nor buying may refill it.
- OID
- 02447c…7f75:84
grantStartingPurseIfNew details
feeFor
func(priceUgnot int64) int64feeFor is the whole fee formula: floor + price \* rate / 10000, rounded down. Written as (price/10000)\*rate + ((price%10000)\*rate)/10000 rather than price\*rate/10000. The two are identical for every input (write price = 10000q + r; then floor(price\*rate/ 10000) = q\*rate + floor(r\*rate/10000) because q\*rate is an integer), but the first form cannot overflow int64 for any legal input: price \<= maxPriceUgnot = 10^15 and rate \<= maxFeeRateBps = 10^4, so (price/10000)\*rate \<= 10^15 and the remainder term is \< 10^4 \* 10^4. The naive form would overflow at price\*rate > 9.2\*10^18, i.e. a 10^15 price at any rate above 9223 bps. TestFeeArithmeticMatchesTheNaiveFormula pins the identity.
- OID
- 02447c…7f75:85
feeFor details
exactUgnot
func(send chain.Coins, want int64) boolexactUgnot is Buy's payment check: one coin, in ugnot, of exactly want. Not "at least".
- OID
- 02447c…7f75:86
exactUgnot details
coinsString
func(c chain.Coins) stringcoinsString renders an envelope for a refusal message; "nothing" for an empty one.
- OID
- 02447c…7f75:87
coinsString details
listingFor
func(tokenID string) (*v3.listing, bool)- OID
- 02447c…7f75:88
listingFor details
rule
func(key string) int64rule reads a tunable. A missing key is a bug in this file, not a runtime condition, so it panics rather than returning a silent zero -- a zero growHours or a zero levelBase would be a far worse outcome than an aborted call.
- OID
- 02447c…7f75:89
rule details
bandFor
func(key string) (v3.ruleBand, bool)- OID
- 02447c…7f75:90
bandFor details
validNameLength
func(name string) boolvalidNameLength/validNameCharset/hasVisibleNameContent/validNameRune/invisibleLetterRunes: unchanged from v1 and v2, including v2's audit fix F3 (\\uXXXX escapes rather than literal invisible glyphs, so the denylist is readable in an editor and cannot be silently emptied by a formatter that strips default-ignorable characters). See v1's own comments for the exhaustive scan proving the list complete against this toolchain's Unicode version, and realms/garden/NOTES.md for the open product question about combining marks -- that allowlist excludes every combining mark, which makes zalgo names structurally impossible and also makes NFD Latin, Devanagari, Thai, vocalised Hebrew and Arabic unspellable. That trade-off is recorded as the owner's to revisit and is carried forward here unchanged rather than silently re-decided.
- OID
- 02447c…7f75:91
validNameLength details
validNameCharset
func(name string) bool- OID
- 02447c…7f75:92
validNameCharset details
hasVisibleNameContent
func(name string) bool- OID
- 02447c…7f75:93
hasVisibleNameContent details
invisibleLetterRunes
map[int32]bool- OID
- 02447c…7f75:95
invisibleLetterRunes details
validNameRune
func(r int32) bool- OID
- 02447c…7f75:96
validNameRune details
quoteAction
func(part string) stringparseActions parses and validates SaveDay's wire format, enforcing maxActionsPerCall on the delimiter COUNT before strings.Split ever runs (v2's audit fix F5: Split allocates the full result slice before any length check could inspect it, so a megabyte of semicolons allocated half a million short-lived strings before being rejected. strings.Count scans the raw bytes without allocating a result, so the oversized case now costs nothing proportional to the input. The caller always paid their own gas for this, so it was never a cross-user DoS -- the fix makes the claim true, it does not close a vulnerability). quoteAction renders an offending entry for a panic message, TRUNCATED. Without this, the four malformed-action panics below interpolated the caller's own bytes at full length, so a semicolon-free, colon-free megabyte passed both delimiter guards and then built a megabyte-long panic string -- one remaining path that allocated proportional to the input, which is precisely what this function's own comment claims cannot happen. Harmless (about 2x amplification of data already in the transaction, entirely caller-paid, and a transaction abort message is not a markdown sink) but it made the claim false, and the claim ships. 48 bytes is comfortably longer than any legal entry -- "harvest:2:1791600000" is 20 -- so a real player's error message is never truncated and only an abusive one is.
- OID
- 02447c…7f75:97
quoteAction details
parseActions
func(raw string) []v3.saveAction- OID
- 02447c…7f75:98
parseActions details
parseUnixField
func(s string, part string) int64- OID
- 02447c…7f75:99
parseUnixField details
parsePlotField
func(s string, part string) int- OID
- 02447c…7f75:100
parsePlotField details
dayNumber
func(unixSeconds int64) int64dayNumber is the UTC calendar day index for a unix-second timestamp. Integer division by 86400 lands exactly on 00:00 UTC boundaries, matching brief 3.1 with no timezone handling, because unix time is UTC by definition.
- OID
- 02447c…7f75:101
dayNumber details
recordCaredDay
func(g *v3.gnome, day int64)recordCaredDay folds one cared UTC day into the gnome's lifetime count and its 64-day window. See the gnome struct's own comment for the storage shape and DESIGN.md section 2.3 for why the chain records days and never computes the streak. This handles a day arriving AT OR BEFORE lastCaredDay even though SaveDay's replay rule (every action strictly after lastActionAt) makes that unreachable today. Two extra lines to make this function correct on its own terms rather than correct only because of an invariant enforced two hundred lines away. On a contract that can never be edited, a total function is worth more than the lines it costs -- and a reader of this function alone should not have to go and verify SaveDay's ordering rule to know it is sound.
- OID
- 02447c…7f75:102
recordCaredDay details
caredDaysCsv
func(g *v3.gnome) stringcaredDaysCsv decodes the window, newest first.
- OID
- 02447c…7f75:103
caredDaysCsv details
meterNow
func(value int64, setAt int64, now int64, dropEveryHours int64) int64meterNow ports app/js/rules.js's meterNow exactly: work the current value out from a last-known value, the unix second it was set, and how many whole dropEveryHours periods have elapsed since, clamped to \[0, meterMax]. A tunable decay rate re-derives history under the new rate, which is harmless precisely because meters saturate: the affected span is bounded in practice by the 48-hour backfill window, and a long-dormant gnome's belly is already clamped at 0 under either rate. See DESIGN.md section 5.4.
- OID
- 02447c…7f75:104
meterNow details
clampMeter
func(v int64) int64- OID
- 02447c…7f75:105
clampMeter details
canPat
func(lastPatAt int64, now int64, cooldownHours int64) boolcanPat ports app/js/rules.js's canPat. lastPatAt == 0 means never patted; see the gnome struct's comment for why 0 is an unambiguous sentinel here and is not in the JS.
- OID
- 02447c…7f75:106
canPat details
moodNow
func(belly int64, lastPatAt int64, lastHappyEventAt int64, adoptedAt int64, now int64, hungryThreshold int64) stringmoodNow ports app/js/rules.js's CURRENT moodNow, in brief table 3.3's first-match-wins order: hungry, then sulky, then happy, then content. "Asleep" is display-only and has no on-chain representation, exactly as in rules.js. THE adoptedAt FLOOR IS THE FIX FOR A DIVERGENCE v2 CANNOT BE FIXED FOR, and it is the reason this function takes a parameter v2's did not. v2 ported rules.js's mood as it then stood, where a never-patted gnome's "last attention" fell back to the unix epoch, so "no pat for 72 hours" was trivially true and every brand-new gnome read as sulky -- v2's own comment flags this and notes rules.test.js never exercised it. rules.js:103-119 has since been fixed, for a reported bug: "EVERY newly adopted gnome was instantly sulky" and "the very first thing a new player read was '\<name>'s in a huff'". v2 cannot be edited, so the deployed realm and the shipped browser now disagree about the mood of the same gnome, and the chain's answer is the one that applies the XP penalty. This matches the fixed version: neverPatted -> floor "last attention" at adoptedAt, because a gnome has been cared for, by definition, at the moment somebody chose it. rules.js also maxes in lastVisitReceivedAt. This realm has no visit mechanic (DESIGN.md section 7.1), so that input is permanently absent, and rules.js with lastVisitReceivedAt === null gives the identical answer. The floor applies ONLY to a gnome that has genuinely never been patted: rules.js's own comment records that an earlier attempt applied it unconditionally and quietly made neglect unreachable, so a gnome last patted 73 hours ago still sulks here, as it should. hungryThreshold is a PARAMETER rather than a package-constant reference inside the body, so this function is pure in its arguments and its formula can be checked against rules.js without knowing any realm state. It is deliberately NOT named hungryAtOrBelow: that is the name of the package constant every caller passes, and shadowing it here would leave a reader of this body alone unable to tell whether the threshold is fixed or caller-supplied -- on a file whose stated standard is that reading one function should be enough. The older note on why the parameter exists at all: the threshold was once a rule(...) lookup inside the body, so this function is pure in its arguments. It used to read the rule itself while SaveDay had already cached that same value for the planting gate -- harmless today, but it made the answer depend on realm state the signature did not mention, which is exactly the kind of hidden input that makes a formula hard to check against rules.js.
- OID
- 02447c…7f75:107
moodNow details
xpToNextWith
func(level int64, base int64, step int64) int64xpToNext/totalXpForLevel/levelFromXp port app/js/rules.js's functions of the same name: levelBase + levelStep\*level to reach the next level, maxLevel the ceiling. levelBase and levelStep are tunable, so a level is recomputed from totalXp on every save -- the bands only permit them to fall, so a level can rise from a tune and can never drop. arena/v1 reads this level for ranked eligibility, so a tune can make a gnome newly eligible and never newly ineligible. DESIGN.md section 5.4. xpToNextWith/totalXpForLevelWith take the curve's two numbers as ARGUMENTS rather than reading them per call, and levelFromXp below reads them exactly once. This is not style. The obvious shape -- xpToNext() reading rule(keyLevelBase) and rule(keyLevelStep) itself -- puts two avl lookups inside a loop that is inside another loop bounded by maxLevel, so one SaveDay paid on the order of 800 tree reads to work out a level that needs two numbers and some arithmetic. garden/v2 had the numbers as compile-time constants and paid nothing; making them tunable must not quietly cost that.
- OID
- 02447c…7f75:108
xpToNextWith details
totalXpForLevelWith
func(level int64, base int64, step int64) int64- OID
- 02447c…7f75:109
totalXpForLevelWith details
xpToNext
func(level int64) int64xpToNext/totalXpForLevel are the single-argument forms the tests and any future reader want, kept so the curve can be asked about without threading the two numbers by hand.
- OID
- 02447c…7f75:110
xpToNext details
totalXpForLevel
func(level int64) int64- OID
- 02447c…7f75:111
totalXpForLevel details
levelFromXp
func(totalXp int64) int64- OID
- 02447c…7f75:112