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}