Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

garden.gno

44.20 Kb · 1033 lines
   1// Package garden is Gnotchi's Phase 1 on-chain slice, v2: everything v1's garden realm
   2// does (Adopt, GnomeByAddress, Render, soulbound, one gnome per address, the validated
   3// name/hat/coat rules, the OriginSend refusal -- every fix from v1's three audit rounds,
   4// carried over rather than rewritten from memory, see below for exactly which lines are
   5// unchanged from realms/garden/garden.gno) plus the new thing Phase 1 actually needs:
   6// SaveDay, the "one signature saves the whole day" call the brief's section 5.2 and the
   7// build brief's own paragraph 2 promise and that the deployed v1 realm does not have.
   8//
   9// v1 (realms/garden/garden.gno) is deployed to the onyx testnet at
  10// gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/garden/v1 and, per DECISIONS.md,
  11// cannot be edited -- this file is deliberately a sibling, not a modification. v1 is left
  12// completely untouched as the deployed record.
  13//
  14// THIS FILE IS NOT DEPLOYED. Authored and tested locally only, per this assignment's own
  15// brief: no gnokey, no key material, no deploy.
  16//
  17// Everything below this comment that is unchanged from v1 (the soulboundExtension, Adopt's
  18// validation chain, the name-charset allowlist/denylist, Render's path validation,
  19// GnomeByAddress) is carried over with its original comments intact, because those
  20// comments are the audit trail for *why* each check exists -- deleting them to make this
  21// file shorter would silently discard that trail. New material (the care-state fields on
  22// gnome, SaveDay and its helpers, CareState) is clearly marked "v2:" in its own comments.
  23//
  24// SECURITY: the same warning v1 carries applies here unchanged and is reprinted in full,
  25// because it is load-bearing and not something a reader should have to go find in a
  26// different file to understand this one: no code in this realm, now or later, may ever
  27// recover() around a call into ledger or token (grc721's PrivateLedger/Token). The
  28// soulbound guarantee below is real, but it is NOT that the ledger write never happens --
  29// it does, before the panic -- it is that an *uncaught* panic aborts the whole enclosing
  30// transaction, so that write (and everything else the transaction did) is discarded and
  31// never committed on chain. p/nt/grc721/v0/types.gno says this outright, about extension
  32// hooks like soulboundExtension below: "Hooks run after the ledger write so they observe
  33// the post-movement state; only a caller that recovers from the panic keeps that write." A
  34// recover() anywhere between a ledger call and the transaction boundary -- even one added
  35// for an unrelated reason, even one several call-frames away -- would silently convert
  36// every refused transfer into a permanent, successful one. Don't add one. This is not
  37// limited to code textually inside this file: a pure `p/` package this realm calls runs in
  38// garden's own call frame too, so a helper as innocuous as
  39// `Try(func(){ ledger.TransferFrom(...) })` imported from some future `p/` dependency would
  40// break the same guarantee just as surely as a recover() written here directly. The same
  41// rule now also applies to SaveDay's own validate-then-commit pattern below: SaveDay never
  42// recovers from its own internal panics either, by design -- see that function's comment.
  43package garden
  44
  45import (
  46	"strconv"
  47	"strings"
  48	"time"
  49	"unicode"
  50	"unicode/utf8"
  51
  52	"gno.land/p/nt/avl/v0"
  53	"gno.land/p/nt/grc721/v0"
  54	"gno.land/p/nt/seqid/v0"
  55	"gno.land/p/nt/ufmt/v0"
  56
  57	"chain"
  58	"chain/runtime/unsafe"
  59)
  60
  61const (
  62	collectionName   = "Gnotchi gnomes"
  63	collectionSymbol = "GNOME"
  64
  65	// maxNameLen: unchanged from v1 -- see that file's own comment for why this number is
  66	// kept in step with app/js/config.js's NAME_MAX_LEN by hand rather than generated.
  67	maxNameLen = 24
  68
  69	// ---- v2: game-rule constants, ported from app/js/rules.config.json by hand so the
  70	// realm gives the same answers rules.js does for the same inputs (this assignment's own
  71	// instruction: "that file is the answer key and the realm must agree with it"). Kept as
  72	// named Gno constants, not re-read from the JSON file at build time (Gno has no way to
  73	// read a JSON file at compile time and no file access at run time), so -- exactly like
  74	// maxNameLen above and app/js/config.js's NAME_MAX_LEN -- these must be updated by hand
  75	// if rules.config.json ever changes. Every value below is cited against the exact
  76	// rules.config.json key it mirrors.
  77	hourSeconds = int64(3600) // rules.config.json time.hourMs / 1000
  78
  79	patCooldownHours = int64(12) // time.patCooldownHours
  80	happyWindowHours = int64(2)  // time.happyWindowHours
  81	sulkyAfterHours  = int64(72) // time.sulkyAfterHours
  82
  83	meterMax            = int64(10) // meters.max
  84	bellyDropEveryHours = int64(3)  // meters.bellyDropEveryHours
  85	cheerDropEveryHours = int64(6)  // meters.cheerDropEveryHours
  86	feedBelly           = int64(4)  // meters.feedBelly
  87	patCheer            = int64(3)  // meters.patCheer
  88	hungryAtOrBelow     = int64(2)  // meters.hungryAtOrBelow
  89
  90	acornsStart    = int64(10) // acorns.start
  91	plantCost      = int64(1)  // acorns.plantCost
  92	harvestPerPlot = int64(3)  // acorns.harvestPerPlot
  93	numPlots       = 3         // acorns.plots
  94	growHours      = int64(24) // acorns.growHours
  95	feedCost       = int64(1)  // acorns.feedCost
  96	tinkerCost     = int64(5)  // acorns.tinkerCost
  97
  98	xpPat            = int64(5)  // xp.pat
  99	xpFeed           = int64(5)  // xp.feed
 100	xpHarvestPerPlot = int64(10) // xp.harvestPerPlot
 101	xpTinker         = int64(15) // xp.tinker
 102	dailyCareCap     = int64(60) // xp.dailyCareCap
 103
 104	levelBase   = int64(50) // xp.levelBase
 105	levelStep   = int64(25) // xp.levelStep
 106	maxLevel    = int64(20) // xp.maxLevel
 107	typeAtLevel = int64(5)  // xp.typeAtLevel
 108
 109	// maxActionsPerCall is this realm's own choice, not a rules.config.json value -- the
 110	// brief gives no explicit cap on a SaveDay action list's length. Set well above
 111	// anything one real UTC day can legitimately produce (at most 2 pats 12h apart, 3
 112	// plots, 1 tinker task, and feeds/plants bounded by the acorn economy itself -- see
 113	// SaveDay's own comment) and far below anything that would let a hostile caller's list
 114	// cost meaningful, unbounded gas to merely parse and reject. See this directory's own
 115	// NOTES.md (realms/garden/v2/NOTES.md, not the repo root's), where this and every other
 116	// "see NOTES.md" below point.
 117	maxActionsPerCall = 32
 118
 119	// maxBackfillHours: v2, audit fix F2, an owner decision (see NOTES.md) -- not a
 120	// rules.config.json value, there is no such key. Before this fix, an action's
 121	// timestamp was bounded below only by the gnome's own lastActionAt, with no bound
 122	// relative to real wall-clock "now", so a player dormant for a year could submit a
 123	// year of backfilled daily care in one sitting. 48 hours forgives the ordinary
 124	// "played last night, forgot to press Save before bed" case without forgiving a week
 125	// off, let alone a year -- the owner's own words were that a player who ignores the
 126	// game for a year "can't just come back and claim it all in one go." Widening this
 127	// number later is safe (it only ever grants more than today, never retroactively
 128	// revokes anything already saved); narrowing it later is not (a player who has
 129	// structured their play around today's number could lose care they'd have been able
 130	// to save a moment earlier) -- so this starts tight by design. See SaveDay's own
 131	// comment for exactly how this bound is enforced.
 132	maxBackfillHours = int64(48)
 133
 134	moodHungry  = "hungry"
 135	moodSulky   = "sulky"
 136	moodHappy   = "happy"
 137	moodContent = "content"
 138
 139	actionPat     = "pat"
 140	actionFeed    = "feed"
 141	actionPlant   = "plant"
 142	actionHarvest = "harvest"
 143	actionTinker  = "tinker"
 144)
 145
 146// validHats/validCoats: unchanged from v1.
 147var validHats = map[string]bool{
 148	"red":    true,
 149	"purple": true,
 150	"green":  true,
 151	"navy":   true,
 152}
 153
 154var validCoats = map[string]bool{
 155	"blue":   true,
 156	"teal":   true,
 157	"orange": true,
 158	"brown":  true,
 159}
 160
 161// plotState: v2. One of this gnome's numPlots garden plots. growing=false means empty
 162// (plantable); growing=true means occupied since plantedAt, harvestable once growHours
 163// have passed.
 164type plotState struct {
 165	growing   bool
 166	plantedAt int64 // unix seconds; meaningless when growing is false
 167}
 168
 169// gnome: v1's fields (name, hat, coat, level, gnomeType, adoptedAt) are unchanged in
 170// meaning and position; v2 adds the care-state fields SaveDay reads and writes. Per the
 171// brief ("Store meters as timestamps, not as decaying numbers") belly/cheer are stored as
 172// a last-known value plus the unix second it was set at, and meterNow below works out the
 173// current value from elapsed time whenever anything reads it -- nothing ticks on this
 174// realm's own clock, there is no scheduled job, and nothing here is updated by anything
 175// other than a SaveDay call the owning address itself signed.
 176type gnome struct {
 177	name      string
 178	hat       string
 179	coat      string
 180	level     int64
 181	gnomeType string // empty until level typeAtLevel assigns one, or forever on a tie -- see assignType
 182	adoptedAt int64  // unix seconds
 183
 184	// v2: meters, stored as (last-set value, the unix second it was set), per the brief.
 185	belly   int64
 186	bellyAt int64
 187	cheer   int64
 188	cheerAt int64
 189
 190	// v2: attention tracking for moodNow. lastPatAt == 0 means "never patted" -- see
 191	// canPat's comment for why 0 is a safe sentinel here (unlike rules.js's canPat, which
 192	// warns against treating 0 as falsy because JS timestamps are milliseconds-since-1970
 193	// and 0 is reachable in a test fixture; a Gno gnome's lastPatAt is only ever written
 194	// from a real chain's time.Now().Unix(), which is never 0 on any chain that has left
 195	// genesis, so 0 is unambiguously "hasn't happened" here).
 196	lastPatAt        int64
 197	lastHappyEventAt int64 // last pat/feed/(win/visit, not in this version) timestamp
 198
 199	// v2: acorns. Rule 3's "earned-only... can't be bought, sold or sent" is enforced
 200	// structurally, not by convention: this field is never exported, nothing in this
 201	// package's exported surface (Adopt, GnomeByAddress, Render, SaveDay, CareState) takes
 202	// an acorn amount or a second address as an argument anywhere, and every mutation of
 203	// this field below (-plantCost, -feedCost, -tinkerCost, +harvestPerPlot, the
 204	// acornsStart at Adopt) only ever touches the calling address's own gnome. There is no
 205	// function signature in this file that could accept "move N acorns from X to Y" even
 206	// if someone wanted to add one by mistake, short of writing a wholly new exported
 207	// function -- which TestAcornsCannotBeTransferred below exists to keep visible if that
 208	// ever changes.
 209	acorns int64
 210
 211	// v2: XP. totalXp is mood-adjusted and capped per calendar day (see SaveDay) and is
 212	// the only thing levelFromXp reads. xpGarden/xpTinker are lifetime, UNCAPPED,
 213	// mood-UNADJUSTED tallies of raw harvest/tinker XP, used only to break the level-5 type
 214	// tie per assignType -- see that function's comment for why these two counters are
 215	// deliberately not the same number as what feeds totalXp. There is no xpExplorer field
 216	// because this version has no Visit call (out of scope per this assignment's brief);
 217	// assignType is always called with explorerXp hard-coded to 0 until a future version
 218	// adds visits.
 219	totalXp  int64
 220	xpGarden int64
 221	xpTinker int64
 222
 223	// v2, audit fix F1: dailyCareXp/dailyCareDay are the per-calendar-day accumulator
 224	// dailyCareCap must actually bound. Earlier code clamped only the current SaveDay call's
 225	// own adjusted XP to dailyCareCap and added it straight to totalXp -- with no record of
 226	// how much of that day's allowance a PREVIOUS call already spent, a second call later the
 227	// same UTC day passed every replay/ordering/same-day check (its timestamps are strictly
 228	// after the first call's lastActionAt) and was granted its own independent 60-XP
 229	// allowance: two calls, 60 each, 120 against a cap of 60. dailyCareDay is the UTC day
 230	// number (dayNumber) the accumulator currently covers; dailyCareXp is reset to 0 whenever
 231	// a SaveDay call's own day differs from it. See SaveDay's own comment for the clamp.
 232	dailyCareXp  int64
 233	dailyCareDay int64
 234
 235	plots [numPlots]plotState
 236
 237	lastTinkerAt int64 // unix seconds of the last completed tinker task; 0 = never
 238
 239	// lastActionAt is the replay/ordering frontier: the unix second of the most recent
 240	// action any successful SaveDay call has ever applied to this gnome. Initialised to
 241	// adoptedAt at Adopt time (so no action can ever be dated at-or-before adoption) and
 242	// advanced to the last action's timestamp at the end of every successful SaveDay call.
 243	// See SaveDay's comment for the whole threat model this one field closes.
 244	lastActionAt int64
 245}
 246
 247var (
 248	token  *grc721.Token
 249	ledger *grc721.PrivateLedger
 250
 251	nextID seqid.ID
 252
 253	gnomes     = avl.NewTree()
 254	ownerIndex = avl.NewTree()
 255)
 256
 257func init(cur realm) {
 258	token, ledger = grc721.NewToken(collectionName, collectionSymbol, 0, cur)
 259	ledger.RegisterExtension(&soulboundExtension{})
 260}
 261
 262// soulboundExtension: unchanged from v1. Rule 6 ("every transfer fails") made structural.
 263// See the package doc above and v1's own long comment (realms/garden/garden.gno) for the
 264// full writeup of why this relies on the transaction boundary rather than hook ordering,
 265// and why TransferFrom being a plain method (not a crossing call) is exactly why the
 266// package-level SECURITY note applies to it.
 267type soulboundExtension struct{}
 268
 269func (soulboundExtension) ExtensionKind() string { return "soulbound" }
 270
 271func (soulboundExtension) OnMint(to address, tid grc721.TokenID) {}
 272
 273func (soulboundExtension) OnTransfer(from, to address, tid grc721.TokenID) {
 274	panic("garden: gnomes are soulbound; transfer is permanently disabled")
 275}
 276
 277func (soulboundExtension) OnBurn(tid grc721.TokenID) {}
 278
 279// Adopt: unchanged from v1 in every validation it performs (same order, same messages, same
 280// OriginSend refusal, same caller derivation). v2 only adds initialising the new care-state
 281// fields at the bottom, where v1 only set level/gnomeType/adoptedAt.
 282func Adopt(cur realm, name, hat, coat string) {
 283	// Unchanged from v1: no banker, no withdrawal path, refuse any attached coins outright
 284	// rather than strand them. See v1's own comment for the two honest limits on what this
 285	// guard actually buys (origin-scoped, not frame-scoped; does not prevent a direct bank
 286	// send to garden's address with no call to Adopt at all).
 287	if !unsafe.OriginSend().IsZero() {
 288		panic("garden: adopt accepts no coins; this realm has no banker and cannot return them")
 289	}
 290
 291	caller := cur.Previous().Address()
 292	if !caller.IsValid() {
 293		panic("garden: adopt requires a valid caller address")
 294	}
 295
 296	if alreadyAdopted(caller) {
 297		panic("garden: this address has already adopted a gnome")
 298	}
 299
 300	if !validNameLength(name) {
 301		panic("garden: name must be 1-" + strconv.Itoa(maxNameLen) + " characters")
 302	}
 303
 304	if !validNameCharset(name) {
 305		panic("garden: name contains a character that is not an allowed letter, digit, space, hyphen or apostrophe")
 306	}
 307
 308	if !hasVisibleNameContent(name) {
 309		panic("garden: name must contain at least one visible character")
 310	}
 311
 312	if !validHats[hat] {
 313		panic("garden: invalid hat colour")
 314	}
 315
 316	if !validCoats[coat] {
 317		panic("garden: invalid coat colour")
 318	}
 319
 320	id := nextID.Next()
 321	tid := grc721.TokenID(id.String())
 322
 323	if err := ledger.Mint(caller, tid); err != nil {
 324		panic(err)
 325	}
 326
 327	now := time.Now().Unix()
 328
 329	// v2: a freshly-adopted gnome starts with full belly and cheer (the brief specifies
 330	// decay rates and the starting acorn count, acorns.start = 10, but never states a
 331	// starting meter value -- see NOTES.md). lastPatAt/lastHappyEventAt/lastTinkerAt all
 332	// start at 0 ("never"). lastActionAt starts at adoptedAt itself, which is what makes
 333	// "every SaveDay action must be strictly after lastActionAt" also mean "no action can
 334	// be dated at or before adoption" for free, with no separate check needed.
 335	gnomes.Set(tid.String(), &gnome{
 336		name:         name,
 337		hat:          hat,
 338		coat:         coat,
 339		level:        1,
 340		gnomeType:    "",
 341		adoptedAt:    now,
 342		belly:        meterMax,
 343		bellyAt:      now,
 344		cheer:        meterMax,
 345		cheerAt:      now,
 346		acorns:       acornsStart,
 347		lastActionAt: now,
 348	})
 349	ownerIndex.Set(caller.String(), tid.String())
 350
 351	chain.Emit(
 352		"Adopt",
 353		"owner", caller.String(),
 354		"tokenId", tid.String(),
 355		"name", name,
 356		"hat", hat,
 357		"coat", coat,
 358	)
 359}
 360
 361// alreadyAdopted: unchanged from v1. See that file's comment for why both ownerIndex and
 362// token.BalanceOf are consulted, not either alone.
 363func alreadyAdopted(addr address) bool {
 364	if ownerIndex.Has(addr.String()) {
 365		return true
 366	}
 367
 368	balance, err := token.BalanceOf(addr)
 369	if err != nil {
 370		panic(err)
 371	}
 372	return balance > 0
 373}
 374
 375// validNameLength: unchanged from v1.
 376func validNameLength(name string) bool {
 377	n := utf8.RuneCountInString(name)
 378	return n > 0 && n <= maxNameLen
 379}
 380
 381// validNameCharset: unchanged from v1.
 382func validNameCharset(name string) bool {
 383	for _, r := range name {
 384		if !validNameRune(r) {
 385			return false
 386		}
 387	}
 388	return true
 389}
 390
 391// hasVisibleNameContent: unchanged from v1.
 392func hasVisibleNameContent(name string) bool {
 393	for _, r := range name {
 394		if r != ' ' {
 395			return true
 396		}
 397	}
 398	return false
 399}
 400
 401// invisibleLetterRunes: unchanged from v1 in content -- same four codepoints -- but audit
 402// fix F3 restores v1's own \uXXXX escape form instead of the literal invisible glyphs this
 403// file had drifted to. The content was still correct (verified by codepoint grep at audit
 404// time), but a map keyed on literal default-ignorable characters is by definition unreadable
 405// in an editor or a diff, and any formatter or copy/paste step that strips default-ignorable
 406// characters could silently empty this denylist with no failing test to catch it. \uXXXX
 407// escapes are plain ASCII source, so that failure mode no longer exists, and
 408// TestInvisibleLetterRunesIsExactlyFourPinnedCodepoints below pins both the count and every
 409// individual codepoint so a truncated map fails loudly instead of silently. See v1's own
 410// comment for the exhaustive scan that proves this list complete against the Unicode version
 411// this toolchain ships today, and for what would make it worth re-running.
 412var invisibleLetterRunes = map[rune]bool{
 413	'\u115f': true, // HANGUL CHOSEONG FILLER
 414	'\u1160': true, // HANGUL JUNGSEONG FILLER
 415	'\u3164': true, // HANGUL FILLER
 416	'\uffa0': true, // HALFWIDTH HANGUL FILLER
 417}
 418
 419// validNameRune: unchanged from v1. See that file's comment for the full rationale
 420// (allowlist not denylist, what it guarantees, what it deliberately does not -- the
 421// combining-mark exclusion recorded as an open product question in NOTES.md, and the
 422// cross-script-homoglyph residual that is accepted, not closed).
 423func validNameRune(r rune) bool {
 424	switch r {
 425	case ' ', '-', '\'':
 426		return true
 427	}
 428	if invisibleLetterRunes[r] {
 429		return false
 430	}
 431	return unicode.IsLetter(r) || unicode.IsDigit(r)
 432}
 433
 434// gnomeForAddr: unchanged from v1.
 435func gnomeForAddr(addr address) (tid string, g *gnome, found bool) {
 436	v := ownerIndex.Get(addr.String())
 437	if v == nil {
 438		return "", nil, false
 439	}
 440	tid = v.(string)
 441	gv := gnomes.Get(tid)
 442	if gv == nil {
 443		return "", nil, false
 444	}
 445	return tid, gv.(*gnome), true
 446}
 447
 448// GnomeByAddress: unchanged from v1 -- same signature, same fields, same meaning. v2's new
 449// care-state fields are deliberately NOT added to this tuple, so that nothing about what
 450// v1 already promised here changes; see CareState below for the new read.
 451func GnomeByAddress(addr address) (tokenID, name, hat, coat string, level int64, gnomeType string, adoptedAtUnix int64, found bool) {
 452	tid, g, ok := gnomeForAddr(addr)
 453	if !ok {
 454		return "", "", "", "", 0, "", 0, false
 455	}
 456	return tid, g.name, g.hat, g.coat, g.level, g.gnomeType, g.adoptedAt, true
 457}
 458
 459// CareState is v2's new read: the SaveDay-relevant state GnomeByAddress deliberately
 460// doesn't carry. belly/cheer are returned already decayed to time.Now() (per the brief,
 461// "work out meter values from stored timestamps when they're read" applies to every
 462// reader, not just SaveDay's own internal bookkeeping) -- a caller never sees a stale
 463// stored value and has to re-derive the decay itself.
 464func CareState(addr address) (belly, cheer, acorns, totalXp, lastPatAtUnix, lastActionAtUnix int64, found bool) {
 465	_, g, ok := gnomeForAddr(addr)
 466	if !ok {
 467		return 0, 0, 0, 0, 0, 0, false
 468	}
 469	now := time.Now().Unix()
 470	return meterNow(g.belly, g.bellyAt, now, bellyDropEveryHours),
 471		meterNow(g.cheer, g.cheerAt, now, cheerDropEveryHours),
 472		g.acorns, g.totalXp, g.lastPatAt, g.lastActionAt, true
 473}
 474
 475// Render: unchanged from v1 except one new bullet (Acorns) -- see this function's own
 476// history in v1 for the full writeup of why path is bech32-validated before anything
 477// derived from it is ever echoed back into the page. v2 deliberately does not also render
 478// belly/cheer/garden-plot state here: CareState above is the read for that, and growing
 479// gnoweb's single markdown page to show every care-state field is a product decision for a
 480// later pass, not an engineering requirement this assignment's brief asks for.
 481func Render(path string) string {
 482	if path == "" {
 483		return ufmt.Sprintf(
 484			"# %s\n\nSoulbound gnome collection. Every gnome is non-transferable: see rule 6.\n\n* **Total adopted**: %d\n",
 485			collectionName, token.TotalSupply(),
 486		)
 487	}
 488
 489	addr := address(path)
 490	if !addr.IsValid() {
 491		return ufmt.Sprintf("# %s\n\nNo gnome adopted for an invalid address.\n", collectionName)
 492	}
 493
 494	tid, g, ok := gnomeForAddr(addr)
 495	if !ok {
 496		return ufmt.Sprintf("# %s\n\nNo gnome adopted yet for `%s`.\n", collectionName, addr.String())
 497	}
 498
 499	gnomeType := g.gnomeType
 500	if gnomeType == "" {
 501		gnomeType = "(not yet set)"
 502	}
 503
 504	str := ufmt.Sprintf("# %s\n\n", g.name)
 505	str += ufmt.Sprintf("* **Owner**: %s\n", addr.String())
 506	str += ufmt.Sprintf("* **Token ID**: %s\n", tid)
 507	str += ufmt.Sprintf("* **Level**: %d\n", g.level)
 508	str += ufmt.Sprintf("* **Type**: %s\n", gnomeType)
 509	str += ufmt.Sprintf("* **Look**: %s hat, %s coat\n", g.hat, g.coat)
 510	str += ufmt.Sprintf("* **Acorns**: %d\n", g.acorns)
 511
 512	return str
 513}
 514
 515// ============================================================================
 516// v2: SaveDay and its helpers. Everything below this line is new.
 517// ============================================================================
 518
 519// saveAction is one parsed entry from a SaveDay call's action list.
 520type saveAction struct {
 521	kind string
 522	at   int64 // unix seconds, as claimed by the caller
 523	plot int   // only meaningful for actionPlant/actionHarvest
 524}
 525
 526// SaveDay is brief section 5.2's "one daily save": the day's actions -- pats, feeds,
 527// plants, harvests and a tinker task -- submitted as a single list in one signed call.
 528//
 529// ENCODING, and why it looks like this rather than like the brief's own
 530// "SaveDay(gnomeID, actions)": a public realm function reachable by `maketx call` or
 531// Adena's DoContract can only take arguments gno.land's own call-argument decoder knows how
 532// to build -- read directly from this toolchain's gno.land/pkg/sdk/vm/convert.go
 533// (convertArgToGno): primitive scalars (bool, string, every sized int/uint/float) and
 534// fixed-size-or-slice []byte (base64). There is no case there for a slice of structs, and
 535// no amount of Gno-side type cleverness changes what the transaction's wire format can
 536// carry. So "actions as a list" has to arrive as a single string this function parses
 537// itself, not as a []Action the chain's own tooling has no way to construct from a
 538// gnokey/Adena call. actionsRaw is semicolon-separated entries, each colon-separated:
 539//
 540//	"pat:<unixSeconds>"
 541//	"feed:<unixSeconds>"
 542//	"tinker:<unixSeconds>"
 543//	"plant:<plotIndex>:<unixSeconds>"
 544//	"harvest:<plotIndex>:<unixSeconds>"
 545//
 546// e.g. "pat:1791600000;feed:1791601000;plant:0:1791602000". An empty string means an empty
 547// action list (a legal no-op call). See NOTES.md for this decision in full.
 548//
 549// Also absent from the brief's own signature: gnomeID. v1 and v2 both enforce one gnome
 550// per address with no transfer and no delegation of any kind -- there is no second gnome
 551// an address could ever mean by "gnomeID", and no path by which SaveDay could legitimately
 552// be asked to act on any gnome but the caller's own. Dropping the parameter removes a
 553// whole class of "whose gnome is this call actually touching" bug before it can exist,
 554// rather than adding a check that would only ever have one correct answer anyway. See
 555// NOTES.md.
 556//
 557// THREAT MODEL -- this assignment's own brief asks for this explicitly: actionsRaw is
 558// client-supplied and this realm is the only authority over it, full stop. Specifically
 559// defended below, each against a concrete hostile input:
 560//
 561//   - Replay (resubmitting an already-applied batch, in whole or in part): every action's
 562//     timestamp must be strictly greater than lastActionAt, the gnome's own high-water
 563//     mark of the latest action any past SaveDay call has ever actually applied. A replayed
 564//     batch's first action is, by definition, <= that mark.
 565//   - Duplicate actions within one call: the same strict-increase rule applies action to
 566//     action within the list (actions[i].at < actions[i+1].at required), so two actions
 567//     sharing one timestamp -- the only way a client could claim two things happened at
 568//     once -- is rejected regardless of which two kinds they are.
 569//   - Future-dated actions: every timestamp must be <= time.Now().Unix(), this call's own
 570//     block time. Nothing claimed to have happened hasn't happened yet.
 571//   - Past-dated actions, at or before adoption: subsumed by the replay check above --
 572//     lastActionAt starts at adoptedAt (see Adopt), so no action can ever be dated before
 573//     adoption either.
 574//   - Unlimited backfill after a long dormancy (audit fix F2, an owner decision -- see
 575//     NOTES.md): the two checks above bound an action relative to lastActionAt (below) and
 576//     to now (above), but say nothing about how far *below* now an action may be, so
 577//     nothing stopped a player dormant for a year from backfilling a year of daily care in
 578//     one sitting. maxBackfillHours (48) closes this with two parts, both needed: (1) any
 579//     action older than the window is rejected outright, with a message a player can
 580//     understand, and (2) the floor the ordering/replay check above enforces is itself
 581//     clamped forward to at least now-maxBackfillHours before that loop runs, rather than
 582//     left at the gnome's raw, possibly year-old lastActionAt -- so even if (1) were ever
 583//     weakened or removed by a future edit, the floor the replay check already enforces
 584//     would independently keep the backfillable span bounded. See the validation loop
 585//     below for both parts and NOTES.md for the owner's reasoning on the 48-hour number.
 586//   - Inflated action counts (e.g. fifty pats claimed in one day): canPat's 12-hour spacing
 587//     makes more than two pats a day arithmetically impossible inside one call regardless
 588//     of what the list claims, and feed/plant/tinker are each gated by this gnome's own
 589//     acorn balance, which this exact set of rules is the only thing that can ever
 590//     increase -- there is no action, in this call or any other exported function, that
 591//     credits acorns without the realm itself having applied the matching cost somewhere
 592//     upstream. The realm's own economy is the rate limit; no separate per-type daily
 593//     counter is needed on top of it.
 594//   - A thousand actions in one list: rejected by maxActionsPerCall before a single entry is
 595//     even parsed -- parseActions counts the ";" delimiters first and panics on an oversized
 596//     count before calling strings.Split at all, so an oversized actionsRaw never pays the
 597//     cost of allocating a huge split result just to be rejected a moment later.
 598//   - XP inflation via the daily cap: rawCareXp for the whole call is mood-adjusted, then
 599//     clamped to whatever remains of dailyCareCap for the UTC calendar day this call's own
 600//     actions fall on -- tracked by the gnome's own dailyCareXp/dailyCareDay accumulator, not
 601//     by anything scoped to one call. Audit fix F1: earlier code capped only the current
 602//     call's own XP at dailyCareCap and had no record of what a PREVIOUS call the same day
 603//     had already spent, so a second same-day call got its own independent 60-XP allowance
 604//     (two calls, 60 each, 120 against a cap of 60). See the cap/mood block below and the
 605//     gnome struct's own comment on dailyCareXp/dailyCareDay.
 606//
 607// ATOMICITY: every check below runs against a local copy (work) of the caller's own
 608// gnome, taken by value at the top of this function. Nothing is written back to the
 609// gnomes tree until every action in the list has validated and applied cleanly against
 610// that copy; any panic before the final commit leaves the persisted gnome completely
 611// untouched, with no partial effect to undo. This is deliberately a plain Go/Gno value
 612// copy, not a recover()-and-rollback: see the package-level SECURITY note for why a
 613// recover() anywhere in this realm is never the right tool, including here.
 614func SaveDay(cur realm, actionsRaw string) {
 615	if !unsafe.OriginSend().IsZero() {
 616		panic("garden: save day accepts no coins; this realm has no banker and cannot return them")
 617	}
 618
 619	caller := cur.Previous().Address()
 620	if !caller.IsValid() {
 621		panic("garden: save day requires a valid caller address")
 622	}
 623
 624	tid, g, ok := gnomeForAddr(caller)
 625	if !ok {
 626		panic("garden: no gnome adopted for this address")
 627	}
 628
 629	now := time.Now().Unix()
 630	actions := parseActions(actionsRaw)
 631
 632	if len(actions) == 0 {
 633		chain.Emit("SaveDay", "owner", caller.String(), "tokenId", tid, "actions", "0", "xpApplied", "0")
 634		return
 635	}
 636
 637	// Whole-batch ordering/bounds/same-day validation, before any mutation. firstDay anchors
 638	// the "one calendar day per call" rule: SaveDay is brief 5.2's "one daily save", so a
 639	// call spanning more than one UTC day is rejected outright rather than guessed at how to
 640	// split -- see NOTES.md for why this is the simplest sound reading, not the only
 641	// conceivable one.
 642	//
 643	// v2, audit fix F2 (owner's decision -- see NOTES.md): earliestAllowed is the backfill
 644	// floor, maxBackfillHours before THIS call's own "now". Two independent uses of it below,
 645	// both required:
 646	//
 647	//  1. Every action's own timestamp is checked directly against earliestAllowed inside the
 648	//     loop and panics with a player-facing message if it is older than the window allows.
 649	//  2. prevAt -- the floor the pre-existing strictly-increasing/replay check enforces -- is
 650	//     clamped forward to earliestAllowed here, before the loop starts, instead of being
 651	//     seeded straight from the gnome's own (possibly year-old) lastActionAt. This is
 652	//     deliberately not left to check 1 alone: clamping prevAt means the SAME
 653	//     already-proven strictly-increasing machinery that catches replay and duplicate
 654	//     timestamps above also independently enforces this exact floor, so the bound does
 655	//     not rest on a single, separately-written comparison that a future edit could weaken
 656	//     or delete without anything else noticing. A gnome dormant far longer than the
 657	//     window still only ever gets prevAt clamped to earliestAllowed, never further back --
 658	//     see NOTES.md for the test that exercises this distinction directly.
 659	earliestAllowed := now - maxBackfillHours*hourSeconds
 660
 661	firstDay := dayNumber(actions[0].at)
 662	prevAt := g.lastActionAt
 663	if prevAt < earliestAllowed {
 664		prevAt = earliestAllowed
 665	}
 666	for _, a := range actions {
 667		if a.at <= earliestAllowed {
 668			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")
 669		}
 670		if a.at <= prevAt {
 671			panic("garden: action timestamp must be strictly after the last saved action")
 672		}
 673		if a.at > now {
 674			panic("garden: action timestamp cannot be in the future")
 675		}
 676		if dayNumber(a.at) != firstDay {
 677			panic("garden: every action in one SaveDay call must fall on the same UTC day")
 678		}
 679		prevAt = a.at
 680	}
 681
 682	// Apply pass: mutate a local copy only. work.plots is a fixed-size array (a value
 683	// type), so `work := *g` copies it in full -- mutating work.plots[i] below never
 684	// touches g.plots[i] until the explicit commit at the end.
 685	work := *g
 686	rawCareXp := int64(0)
 687	tinkeredThisCall := false
 688
 689	for _, a := range actions {
 690		switch a.kind {
 691		case actionPat:
 692			if !canPat(work.lastPatAt, a.at) {
 693				panic("garden: pat must be at least 12 hours after the last one")
 694			}
 695			work.lastPatAt = a.at
 696			work.lastHappyEventAt = a.at
 697			work.cheer = clampMeter(meterNow(work.cheer, work.cheerAt, a.at, cheerDropEveryHours) + patCheer)
 698			work.cheerAt = a.at
 699			rawCareXp += xpPat
 700
 701		case actionFeed:
 702			if work.acorns < feedCost {
 703				panic("garden: not enough acorns to feed")
 704			}
 705			work.acorns -= feedCost
 706			work.belly = clampMeter(meterNow(work.belly, work.bellyAt, a.at, bellyDropEveryHours) + feedBelly)
 707			work.bellyAt = a.at
 708			work.lastHappyEventAt = a.at
 709			rawCareXp += xpFeed
 710
 711		case actionPlant:
 712			bellyAtAction := meterNow(work.belly, work.bellyAt, a.at, bellyDropEveryHours)
 713			if bellyAtAction <= hungryAtOrBelow {
 714				// Table 3.3: "Hungry: can't garden until fed." v2 reads this as blocking
 715				// new planting only, not harvesting an already-grown plot -- see NOTES.md
 716				// for why (rule 9: the gnome never loses progress; a ready harvest should
 717				// never be strandable behind an unrelated hunger gate).
 718				panic("garden: too hungry to garden; feed first")
 719			}
 720			p := &work.plots[a.plot]
 721			if p.growing {
 722				panic("garden: that plot is already planted")
 723			}
 724			if work.acorns < plantCost {
 725				panic("garden: not enough acorns to plant")
 726			}
 727			work.acorns -= plantCost
 728			p.growing = true
 729			p.plantedAt = a.at
 730
 731		case actionHarvest:
 732			p := &work.plots[a.plot]
 733			if !p.growing {
 734				panic("garden: that plot has nothing to harvest")
 735			}
 736			if a.at-p.plantedAt < growHours*hourSeconds {
 737				panic("garden: that plot is not ready to harvest yet")
 738			}
 739			p.growing = false
 740			p.plantedAt = 0
 741			work.acorns += harvestPerPlot
 742			work.xpGarden += xpHarvestPerPlot
 743			rawCareXp += xpHarvestPerPlot
 744
 745		case actionTinker:
 746			if tinkeredThisCall {
 747				panic("garden: only one tinker task per day")
 748			}
 749			if work.lastTinkerAt != 0 && dayNumber(work.lastTinkerAt) == dayNumber(a.at) {
 750				panic("garden: already did a tinker task today")
 751			}
 752			if work.acorns < tinkerCost {
 753				panic("garden: not enough acorns to tinker")
 754			}
 755			work.acorns -= tinkerCost
 756			work.xpTinker += xpTinker
 757			work.lastTinkerAt = a.at
 758			tinkeredThisCall = true
 759			rawCareXp += xpTinker
 760
 761		default:
 762			// Unreachable: parseActions already rejects any kind outside this set.
 763			panic("garden: unknown action kind")
 764		}
 765	}
 766
 767	// Mood snapshot for the whole call's raw care XP -- matching app/js/rules.js's
 768	// careXpForDay(rawCareXp, mood), which also takes one mood value for an entire day's
 769	// total rather than re-deriving mood per action. The "now" used for this mood snapshot is
 770	// latestActionAt: the LAST action's own timestamp, using the state exactly as this call
 771	// leaves it, not the state from before any of today's actions.
 772	//
 773	// Audit fix F1, naming half: this variable used to be called endOfDay, which reads as
 774	// "the day is closed" and actually means "the last action this call happened to process"
 775	// -- a claim about this call's own list, not about the calendar day at all. That gap is
 776	// plausibly what hid the F1 cap bug (below) from its own author and from the tests: a
 777	// name that says "the day is over" invites the reader to assume the day's allowance has
 778	// been fully accounted for here, when nothing before this fix actually tracked that
 779	// across calls. Renamed to say only what it is.
 780	latestActionAt := actions[len(actions)-1].at
 781	bellyAtEnd := meterNow(work.belly, work.bellyAt, latestActionAt, bellyDropEveryHours)
 782	mood := moodNow(bellyAtEnd, work.lastPatAt, work.lastHappyEventAt, latestActionAt)
 783
 784	adjustedXp := rawCareXp
 785	switch mood {
 786	case moodHappy:
 787		// xp*11/10, integer division: exactly floor(xp*1.1) for every non-negative xp, with
 788		// no floating-point representation error -- see NOTES.md for why this is a more
 789		// precise reimplementation of rules.js's Math.floor(xp*(1+0.1)), not a different
 790		// rule, and why every case rules.test.js actually exercises agrees with it exactly.
 791		adjustedXp = adjustedXp * 11 / 10
 792	case moodSulky:
 793		adjustedXp = adjustedXp * 9 / 10
 794	}
 795
 796	// Audit fix F1, cap half: dailyCareCap must bound a UTC CALENDAR DAY's total care XP,
 797	// not one call's. work.dailyCareDay/work.dailyCareXp (see the gnome struct's own comment)
 798	// is the accumulator that makes that true across calls: reset to 0 the moment this call's
 799	// own day (firstDay, computed and validated against every action above) differs from
 800	// whatever day the accumulator currently covers, then clamp this call's adjusted XP to
 801	// whatever is actually left of the cap, and top the accumulator up by exactly that much
 802	// -- never by the pre-clamp adjustedXp. A second call later the same day now sees a
 803	// non-zero dailyCareXp left over from the first and gets only the remainder, instead of
 804	// its own independent 60-point allowance.
 805	if work.dailyCareDay != firstDay {
 806		work.dailyCareDay = firstDay
 807		work.dailyCareXp = 0
 808	}
 809	remainingToday := dailyCareCap - work.dailyCareXp
 810	if remainingToday < 0 {
 811		remainingToday = 0
 812	}
 813	if adjustedXp > remainingToday {
 814		adjustedXp = remainingToday
 815	}
 816	work.dailyCareXp += adjustedXp
 817
 818	work.totalXp += adjustedXp
 819	work.level = levelFromXp(work.totalXp)
 820
 821	// Type assignment at level typeAtLevel: see assignType's own comment for the tie rule.
 822	if work.gnomeType == "" && work.level >= typeAtLevel {
 823		work.gnomeType = assignType(work.xpGarden, 0, work.xpTinker)
 824	}
 825
 826	work.lastActionAt = latestActionAt
 827
 828	*g = work
 829	// Explicit re-Set after the in-place *g = work commit above: g is already the same
 830	// *gnome pointer the gnomes tree holds (see gnomeForAddr), so this is a zero-cost no-op
 831	// today, not a behaviour change -- it removes any question about relying on in-place
 832	// mutation through a stored pointer that could not be settled by reading the code alone,
 833	// per the audit's own smaller-items list.
 834	gnomes.Set(tid, g)
 835
 836	chain.Emit(
 837		"SaveDay",
 838		"owner", caller.String(),
 839		"tokenId", tid,
 840		"actions", strconv.Itoa(len(actions)),
 841		"xpApplied", strconv.FormatInt(adjustedXp, 10),
 842		"mood", mood,
 843	)
 844}
 845
 846// parseActions parses and validates the wire format SaveDay's own comment documents, and
 847// enforces maxActionsPerCall before doing any per-entry work -- a caller who sends a
 848// thousand ";"-separated entries is rejected on the delimiter COUNT alone, before
 849// strings.Split ever runs.
 850//
 851// Audit fix F5: this used to call strings.Split(raw, ";") first and check len(parts) >
 852// maxActionsPerCall second -- but Split itself allocates the full []string result (one
 853// allocation per entry) before that length is ever inspected, so a megabyte of semicolons
 854// allocated half a million short-lived strings before being rejected. SaveDay's own comment
 855// claims an oversized list is rejected "before a single entry is even parsed," which was
 856// true of per-entry parsing but not of this allocation. strings.Count scans the raw bytes
 857// for the delimiter without allocating a result slice, so the oversized case is now rejected
 858// without allocating anything proportional to the attacker's input at all. The caller still
 859// pays their own gas for whatever they send, so this was never a DoS on other users -- this
 860// fix makes the comment's own claim true, it does not close a cross-user vulnerability.
 861func parseActions(raw string) []saveAction {
 862	if raw == "" {
 863		return nil
 864	}
 865
 866	if n := strings.Count(raw, ";") + 1; n > maxActionsPerCall {
 867		panic("garden: too many actions in one call (max " + strconv.Itoa(maxActionsPerCall) + ")")
 868	}
 869
 870	parts := strings.Split(raw, ";")
 871
 872	actions := make([]saveAction, 0, len(parts))
 873	for _, part := range parts {
 874		fields := strings.Split(part, ":")
 875		switch fields[0] {
 876		case actionPat, actionFeed, actionTinker:
 877			if len(fields) != 2 {
 878				panic("garden: malformed action \"" + part + "\"")
 879			}
 880			actions = append(actions, saveAction{
 881				kind: fields[0],
 882				at:   parseUnixField(fields[1], part),
 883			})
 884		case actionPlant, actionHarvest:
 885			if len(fields) != 3 {
 886				panic("garden: malformed action \"" + part + "\"")
 887			}
 888			actions = append(actions, saveAction{
 889				kind: fields[0],
 890				plot: parsePlotField(fields[1], part),
 891				at:   parseUnixField(fields[2], part),
 892			})
 893		default:
 894			panic("garden: unknown action kind in \"" + part + "\"")
 895		}
 896	}
 897	return actions
 898}
 899
 900func parseUnixField(s, part string) int64 {
 901	v, err := strconv.ParseInt(s, 10, 64)
 902	if err != nil || v <= 0 {
 903		panic("garden: invalid timestamp in action \"" + part + "\"")
 904	}
 905	return v
 906}
 907
 908func parsePlotField(s, part string) int {
 909	v, err := strconv.Atoi(s)
 910	if err != nil || v < 0 || v >= numPlots {
 911		panic("garden: invalid plot index in action \"" + part + "\"")
 912	}
 913	return v
 914}
 915
 916// dayNumber is the UTC calendar day index for a unix-second timestamp -- integer division
 917// by 86400 lands exactly on 00:00 UTC boundaries, matching brief 3.1 ("a day for daily
 918// limits runs from 00:00 to 24:00 UTC") without any timezone handling, because unix time
 919// is UTC by definition.
 920func dayNumber(unixSeconds int64) int64 { return unixSeconds / 86400 }
 921
 922// meterNow ports app/js/rules.js's meterNow exactly: work out the current value from a
 923// last-known value, the unix second it was set, and how many whole dropEveryHours periods
 924// have elapsed since, clamped to [0, meterMax].
 925func meterNow(value, setAt, now, dropEveryHours int64) int64 {
 926	if now < setAt {
 927		now = setAt
 928	}
 929	drops := (now - setAt) / (dropEveryHours * hourSeconds)
 930	v := value - drops
 931	return clampMeter(v)
 932}
 933
 934func clampMeter(v int64) int64 {
 935	if v < 0 {
 936		return 0
 937	}
 938	if v > meterMax {
 939		return meterMax
 940	}
 941	return v
 942}
 943
 944// canPat ports app/js/rules.js's canPat: lastPatAt == 0 means never patted (see gnome's own
 945// comment for why 0 is an unambiguous sentinel here, unlike in the JS version).
 946func canPat(lastPatAt, now int64) bool {
 947	return lastPatAt == 0 || now-lastPatAt >= patCooldownHours*hourSeconds
 948}
 949
 950// moodNow ports app/js/rules.js's moodNow, in the same first-match-wins order as brief
 951// table 3.3: hungry, then sulky, then happy, then content. "Asleep" is display-only (brief
 952// 3.1) and has no on-chain representation at all, exactly as in rules.js.
 953//
 954// A gnome that has never been patted (lastPatAt == 0) reads as sulky here, immediately,
 955// even on the day it was adopted, if that day's batch never includes a pat -- ported
 956// faithfully from rules.js's own `Math.max(g.lastPatAt || 0, ...)`, which has the identical
 957// property (a never-patted gnome's "last attention" is treated as unix-epoch-zero, so
 958// "no pat for 72 hours" is trivially true). rules.test.js never exercises this edge case
 959// (every mood fixture there sets lastPatAt to a real recent-ish value), so this is a
 960// faithful port of the answer key's actual formula, not a verified-by-the-answer-key's-own-
 961// tests behaviour -- flagged here rather than silently relied on. See NOTES.md.
 962func moodNow(belly, lastPatAt, lastHappyEventAt, now int64) string {
 963	if belly <= hungryAtOrBelow {
 964		return moodHungry
 965	}
 966	if now-lastPatAt >= sulkyAfterHours*hourSeconds {
 967		return moodSulky
 968	}
 969	if lastHappyEventAt != 0 && now-lastHappyEventAt <= happyWindowHours*hourSeconds {
 970		return moodHappy
 971	}
 972	return moodContent
 973}
 974
 975// xpToNext/totalXpForLevel/levelFromXp port app/js/rules.js's functions of the same name,
 976// line for line in spirit: 50 + 25*level to reach the next level, level 20 the maximum.
 977func xpToNext(level int64) int64 { return levelBase + levelStep*level }
 978
 979func totalXpForLevel(level int64) int64 {
 980	var t int64
 981	for l := int64(1); l < level; l++ {
 982		t += xpToNext(l)
 983	}
 984	return t
 985}
 986
 987func levelFromXp(totalXp int64) int64 {
 988	level := int64(1)
 989	for level < maxLevel && totalXp >= totalXpForLevel(level+1) {
 990		level++
 991	}
 992	return level
 993}
 994
 995// assignType ports app/js/rules.js's assignType. On a genuine tie (including the trivial
 996// three-way tie of a gnome that reached level 5 on pats and feeds alone, which count
 997// towards no type at all) this returns "" rather than guessing: the brief says "the player
 998// chooses", and this version has no chooser endpoint (StartRetrain/an explicit "choose
 999// type" call is out of scope here) -- see NOTES.md. This mirrors v1's own existing
1000// convention that an empty gnomeType is a valid, documented, permanent-until-resolved
1001// state (Render already prints "(not yet set)" for it).
1002func assignType(gardenXp, explorerXp, tinkerXp int64) string {
1003	best := gardenXp
1004	if explorerXp > best {
1005		best = explorerXp
1006	}
1007	if tinkerXp > best {
1008		best = tinkerXp
1009	}
1010
1011	tiedCount := 0
1012	if gardenXp == best {
1013		tiedCount++
1014	}
1015	if explorerXp == best {
1016		tiedCount++
1017	}
1018	if tinkerXp == best {
1019		tiedCount++
1020	}
1021	if tiedCount != 1 {
1022		return ""
1023	}
1024
1025	switch {
1026	case gardenXp == best:
1027		return "gardener"
1028	case explorerXp == best:
1029		return "explorer"
1030	default:
1031		return "tinkerer"
1032	}
1033}