hollowmere.gno
19.77 Kb · 382 lines
1// Package hollowmere is the on-chain slice of Book One, "The Three Ember Seeds" -- the
2// adventure's own chain record, separate from garden/v2's pet-care record, per the
3// owner's ask: "the adventure game needs chain features."
4//
5// Authored and tested without gnokey or any key material, per this assignment's own
6// brief. Whether and when this package has actually been deployed is deliberately not
7// asserted here: this comment ships on chain exactly as written the moment it is
8// deployed, and a realm cannot be edited afterwards, so a "not deployed yet" sentence
9// here would go permanently stale at the exact moment it stopped being true -- see
10// garden/v2/gnomod.toml's own history for why this is a real failure mode, not a
11// hypothetical one. Deploy status belongs in DECISIONS.md and STATUS.md, which can still
12// be edited after the fact.
13//
14// ============================================================================
15// THE HONESTY PROBLEM THIS PACKAGE EXISTS INSIDE -- READ THIS FIRST
16// ============================================================================
17//
18// The adventure's levels run in the browser. This chain cannot watch a player solve the
19// rune-rhyme puzzle, bonk the Great Mothkin three times, or do anything else Book One's
20// plan describes -- it can only watch a wallet sign a transaction that ASSERTS those
21// things happened. The plan itself says this plainly: "the levels run in the browser, so
22// a determined cheat can poke at them" (pack-update/01-adventure-plan/book-one-plan.md,
23// "Risks and decisions for Seán").
24//
25// So this package is commented, named and worded throughout to make that limit impossible
26// to miss -- carried by the comments, the event name, the type names and Render's own
27// wording, not by any single exported function's name in isolation (a player signing
28// ClearChapter(1, 1) in a wallet sees that call and nothing else; the honesty framing has
29// to already live in the surrounding names and docs, because there is no comment attached
30// to a signing prompt):
31//
32// - ClearChapter does not "verify" or "check" a clear. It records a CLAIM: this address
33// says it cleared this chapter, and the chain stamps that claim with the block's own
34// timestamp. Nothing here replays a puzzle, checks an input log, or asks the browser
35// for proof of anything. The function's own comment says so; the bare function name a
36// wallet shows does not carry this by itself.
37// - bookOneClaim, the storage type, and claims, the tree that holds it, are named for
38// what they are: a ledger of claims, not a record of verified outcomes.
39// - The emitted event is named ChapterClaimed, not ChapterCleared or ChapterVerified.
40// - Render's own wording repeats this to the one audience who will actually read it: a
41// player looking at their own public page.
42//
43// What this DOES genuinely give the game, worth having even though it is not refereed:
44// the record is public, permanent, timestamped by the chain rather than the client,
45// tied to a wallet rather than a browser's local storage, and readable by anyone without
46// trusting gnotchi.com's server to tell the truth. For a cosy single-player adventure
47// with nothing to win from another player's loss, that is a sound trade. See NOTES.md for
48// the full writeup of why the owner's narrower ask (chapter purchase, the daily burrow,
49// players' notes) is deliberately NOT built here, rather than half-built to look more
50// complete than it is.
51//
52// ============================================================================
53// SCOPE -- what this package is, and deliberately is not
54// ============================================================================
55//
56// Built: chapter-completion claims (ClearChapter), Ember Seeds derived from those claims
57// (never accepted as an input), a per-address progress read (BookOneClaim), and a public
58// Render page.
59//
60// NOT built, on purpose, not half-built: chapter purchase (nothing is for sale here; this
61// package handles no money at all, enforced the same way garden/v2 does, below), the daily
62// burrow and its leaderboard (needs a dungeon generator that doesn't exist yet), and
63// players' notes (stored free text on a permanent public page re-opens the hostile-name
64// problem garden/garden.gno's own NOTES.md already has open, and deserves its own design
65// pass rather than a rushed one here). See NOTES.md for the full reasoning on each.
66//
67// ============================================================================
68// WHY THIS NEVER CALLS recover()
69// ============================================================================
70//
71// This package calls no grc721 ledger, mints nothing, and has no transfer hook to protect
72// -- the specific recover()-undoes-a-soulbound-guarantee trap garden/garden.gno's own
73// SECURITY note documents does not literally apply here, because there is no ledger write
74// to re-expose. But the underlying reason not to recover() is the same one stated above in
75// different words: this package's entire credibility is "the chain only ever records a
76// claim that fully passed every check in ClearChapter below." Every mutating path in this
77// file validates completely BEFORE touching any stored claim (see ClearChapter's own
78// comment for the exact validate-then-commit sequence), so an uncaught panic already
79// guarantees the Gno/Cosmos SDK transaction boundary discards the whole failed call with
80// no partial claim ever written. A recover() anywhere in this file -- even one added later
81// for an unrelated reason -- could let a claim that failed a check halfway through land
82// anyway, which is exactly the "the chain checked something it didn't" impression this
83// package's own doc above insists it must never give. Don't add one.
84package hollowmere
85
86import (
87 "strconv"
88 "time"
89
90 "gno.land/p/nt/avl/v0"
91 "gno.land/p/nt/ufmt/v0"
92
93 "chain"
94 "chain/runtime/unsafe"
95)
96
97const (
98 // bookOne is the only book number ClearChapter accepts today. Book One, "The Three
99 // Ember Seeds", is the only book this plan has designed chapters for
100 // (pack-update/01-adventure-plan/book-one-plan.md); Book Two is named only as a hook
101 // ("something is drinking the moonlight, and nobody knows what yet") with no chapters,
102 // tools, puzzles or prices written down anywhere this package could build against.
103 bookOne = int64(1)
104
105 // maxBook bounds ClearChapter's own book argument. Only bookOne exists today, so
106 // maxBook == bookOne -- and unlike garden/v2's maxBackfillHours, this is not a
107 // constant that could later be widened in place: this realm is immutable once
108 // deployed, so maxBook is fixed at 1 here forever. A real Book Two would not widen
109 // this constant in this file; it would be a wholly separate realm, with its own
110 // gnomod.toml, its own empty claims tree, and its own bound to set -- it could not
111 // read this package's unexported state even if it wanted to widen anything here. See
112 // NOTES.md.
113 maxBook = bookOne
114
115 // numChaptersBookOne: Book One has exactly four chapters (The Mossy Burrow, The Sunken
116 // Greenhouse, The Cogwork Mill, The Long Night) per the plan's own chapter list. Left
117 // untyped so it doubles as claimedAt's array length below without a conversion.
118 numChaptersBookOne = 4
119
120 // numEmberSeedChapters: only chapters 1-3 each win back one Ember Seed. The plan's own
121 // words: "Each chapter wins one back, and the last chapter brings them home" -- chapter
122 // 4 (The Long Night) is the finale that reunites the three seeds already won, not a
123 // fourth seed. See emberSeedsFor's own comment for how this is derived, never accepted
124 // as a parameter.
125 numEmberSeedChapters = 3
126)
127
128// bookOneClaim is one address's claimed progress through Book One. Named claim, not
129// progress or record, on purpose: every field here is what an address SAID, timestamped
130// by the chain when it said it, never something this package checked against the actual
131// game. See the package doc above.
132type bookOneClaim struct {
133 // highestChapterClaimed is 0 until the first successful ClearChapter call for this
134 // address, then the highest chapter number it has claimed -- chapters claim strictly in
135 // order (see ClearChapter), so "highest" and "count of chapters claimed" are always the
136 // same number for as long as this field exists.
137 highestChapterClaimed int64
138
139 // claimedAt[i] is the unix-second block timestamp (time.Now().Unix() at the moment
140 // ClearChapter accepted chapter i+1's claim for this address), or 0 if chapter i+1 has
141 // not been claimed yet. This is block time, the same chain-trustworthy clock
142 // garden/v2's own SaveDay reads -- never a timestamp the caller supplies, because
143 // unlike SaveDay (which batches a day's worth of actions and genuinely needs a
144 // backfill window for a player who forgot to press Save), ClearChapter records one
145 // single, discrete claim at the moment the wallet signs it. There is nothing to
146 // backfill and nothing a client-supplied timestamp would buy a player that the chain's
147 // own clock doesn't already give them for free.
148 claimedAt [numChaptersBookOne]int64
149}
150
151var (
152 // claims is this package's entire state: a ledger of claims, keyed by
153 // claimKey(address, book), never by address alone. Book One is the only book accepted
154 // today (see maxBook), and -- because this realm is immutable once deployed -- no
155 // migration question from widening maxBook can ever arise here: a real Book Two would
156 // be a different realm entirely, with its own empty tree, unable to read this
157 // package's unexported state at all. Keying by (address, book) anyway costs nothing
158 // (one string-concatenation helper, claimKey) and is simply tidier than keying by
159 // address alone. See NOTES.md.
160 claims = avl.NewTree()
161
162 // totalClaimants: addresses with at least one accepted claim in Book One. Incremented
163 // exactly once per address, the first time ClearChapter accepts any chapter for it --
164 // see ClearChapter's own isNewClaimant handling.
165 totalClaimants int64
166)
167
168// claimKey is the tree key for one (address, book) pair's claim.
169func claimKey(addr address, book int64) string {
170 return addr.String() + ":" + strconv.FormatInt(book, 10)
171}
172
173// ClearChapter records that the calling address CLAIMS to have cleared this chapter of
174// this book. See the package doc above for what this is and is not: a timestamped,
175// public, permanent claim, never a verified outcome.
176//
177// Accepts no coins (this package handles no money at all; see NOTES.md for why chapter
178// purchase is out of scope entirely, not half-built). Chapters must clear in strict order,
179// starting from 1, and each chapter may be claimed at most once per address -- both
180// enforced by the single comparison below against highestChapterClaimed, the same way
181// garden/v2 favours one invariant that closes several hostile cases over several separate
182// ad hoc checks (see that file's SaveDay comment). Ember Seeds are never a parameter here
183// or anywhere in this package: see emberSeedsFor for how they are derived instead.
184//
185// ATOMICITY: every check below runs to completion before anything is written. work is a
186// plain value copy of this address's existing claim (or the zero value, if this is its
187// first), taken after every validation has already passed; the only thing that happens to
188// it is the two-field update at the bottom, so a panic anywhere above that point leaves
189// the persisted claim -- if any -- completely untouched. This mirrors garden/v2's SaveDay
190// pattern deliberately: a plain value copy, not a recover()-and-rollback. See the package
191// doc for why recover() is never the right tool here either.
192func ClearChapter(cur realm, book, chapter int64) {
193 if !unsafe.OriginSend().IsZero() {
194 panic("hollowmere: clearing a chapter accepts no coins; this realm has no banker and cannot return them")
195 }
196
197 caller := cur.Previous().Address()
198 if !caller.IsValid() {
199 panic("hollowmere: clearing a chapter requires a valid caller address")
200 }
201
202 if book < 1 || book > maxBook {
203 panic("hollowmere: book " + strconv.FormatInt(book, 10) + " does not exist; only Book One is playable today")
204 }
205 if chapter < 1 || chapter > int64(numChaptersBookOne) {
206 panic("hollowmere: chapter " + strconv.FormatInt(chapter, 10) + " does not exist in Book One; chapters run 1-4")
207 }
208
209 key := claimKey(caller, book)
210 v := claims.Get(key)
211
212 var existing bookOneClaim
213 isNewClaimant := v == nil
214 if !isNewClaimant {
215 existing = *(v.(*bookOneClaim))
216 }
217
218 if chapter <= existing.highestChapterClaimed {
219 panic("hollowmere: chapter " + strconv.FormatInt(chapter, 10) + " was already claimed cleared for this address")
220 }
221 if chapter > existing.highestChapterClaimed+1 {
222 panic("hollowmere: chapters claim in order; claim chapter " +
223 strconv.FormatInt(existing.highestChapterClaimed+1, 10) +
224 " before claiming chapter " + strconv.FormatInt(chapter, 10))
225 }
226
227 now := time.Now().Unix()
228
229 work := existing
230 work.claimedAt[chapter-1] = now
231 work.highestChapterClaimed = chapter
232
233 claims.Set(key, &work)
234 if isNewClaimant {
235 totalClaimants++
236 }
237
238 chain.Emit(
239 "ChapterClaimed",
240 "address", caller.String(),
241 "book", strconv.FormatInt(book, 10),
242 "chapter", strconv.FormatInt(chapter, 10),
243 "claimedAt", strconv.FormatInt(now, 10),
244 "emberSeeds", strconv.FormatInt(emberSeedsFor(&work), 10),
245 )
246}
247
248// emberSeedsFor derives the number of Ember Seeds this claim implies: one per chapter 1-3
249// claimed cleared (chapter 4 is the finale that brings the three home, not a fourth seed --
250// see numEmberSeedChapters's own comment). This is the ONLY place Ember Seeds are computed
251// anywhere in this package. There is no exported function, field, or parameter anywhere in
252// this file that lets a caller set, add, or otherwise assert an Ember Seed count directly
253// -- it is always read off highestChapterClaimed, never written. A client cannot claim
254// seeds it did not (claim to have) earned, because there is no spelling of a call into
255// this package that takes a seed count as input at all.
256//
257// Reads highestChapterClaimed, never claimedAt: ClearChapter only ever advances both
258// fields together (see its own comment), so the two cannot disagree today -- but
259// highestChapterClaimed is the simpler source of truth. claimedAt uses 0 as its own
260// "not yet claimed" sentinel while also being written from real block time, a dual
261// representation of the same fact with a sentinel to keep straight; highestChapterClaimed
262// needs neither. Capped at numEmberSeedChapters so chapter 4 (the finale, not a fourth
263// seed-winning chapter) never adds a 4th.
264func emberSeedsFor(c *bookOneClaim) int64 {
265 n := c.highestChapterClaimed
266 if n > numEmberSeedChapters {
267 n = numEmberSeedChapters
268 }
269 return n
270}
271
272// BookOneClaim is the progress read for one address: what it has claimed in Book One, and
273// the Ember Seed count derived from those claims. found is false if this address has never
274// had a ClearChapter call accepted for Book One at all.
275func BookOneClaim(addr address) (highestChapterClaimed, chapter1ClaimedAt, chapter2ClaimedAt, chapter3ClaimedAt, chapter4ClaimedAt, emberSeeds int64, found bool) {
276 v := claims.Get(claimKey(addr, bookOne))
277 if v == nil {
278 return 0, 0, 0, 0, 0, 0, false
279 }
280 c := v.(*bookOneClaim)
281 return c.highestChapterClaimed, c.claimedAt[0], c.claimedAt[1], c.claimedAt[2], c.claimedAt[3], emberSeedsFor(c), true
282}
283
284// TotalClaimants returns the number of addresses with at least one accepted chapter claim
285// in Book One. Everything claims holds is already public via Render (there is no privacy
286// cost to exposing it), but before this function the only way to reach this exact number
287// was BookOneClaim(someKnownAddress) for one address at a time, or string-parsing the
288// overview page Render("") returns. Neither lets another realm -- a future Book Two, a
289// shop, a leaderboard -- enumerate or even count claimants, which is a real limitation for
290// a ledger whose entire stated purpose is being publicly readable. See ClaimantsPage below
291// for the enumerator this same gap needed.
292func TotalClaimants() int64 {
293 return totalClaimants
294}
295
296// maxClaimantsPageSize bounds a single ClaimantsPage call's own limit, so that call stays
297// gas-bounded no matter how large claims grows over this realm's lifetime -- a caller
298// cannot make one call's own cost scale with the whole ledger's size just by asking for a
299// bigger page. Picked well above anything a real paging UI needs per page (a leaderboard
300// or an admin view rendering a few dozen rows at a time) and far below a size that would
301// make one call's own tree traversal unbounded.
302const maxClaimantsPageSize = 100
303
304// ClaimantsPage is the paginated enumerator claims needed to be genuinely publicly
305// readable, not just publicly readable one known address at a time. It returns up to
306// limit claim keys (each claimKey(address, book), i.e. "<address>:<book>") in the claims
307// tree's own stable order, starting at offset -- the same (key, value) order
308// claims.IterateByOffset itself walks, which never changes for a fixed tree.
309//
310// limit is CLAMPED to maxClaimantsPageSize rather than rejected outright, so a caller that
311// asks for too much still gets a gas-bounded page back instead of an aborted call; offset
312// and limit are otherwise rejected if non-positive/negative, since those are caller
313// mistakes with no sane page to return.
314//
315// Paging with offset 0, then offset+len(returned page), ... until a page shorter than its
316// own requested limit comes back, covers every claimant exactly once, in this stable
317// order, with no duplicates and no gaps, for any snapshot where claims does not grow
318// between calls -- see hollowmere_test.gno's own paging test, which proves this directly
319// rather than assuming it from the underlying avl.Tree's own doc comment.
320func ClaimantsPage(offset, limit int) []string {
321 if offset < 0 {
322 panic("hollowmere: offset must not be negative")
323 }
324 if limit <= 0 {
325 panic("hollowmere: limit must be positive")
326 }
327 if limit > maxClaimantsPageSize {
328 limit = maxClaimantsPageSize
329 }
330
331 keys := make([]string, 0, limit)
332 claims.IterateByOffset(offset, limit, func(key string, value any) bool {
333 keys = append(keys, key)
334 return false
335 })
336 return keys
337}
338
339// Render gives each player a public page on gno.land. Path validated with
340// address(path).IsValid() before anything derived from it is echoed back -- the same
341// guard garden/v2's own Render uses, and for the same reason: an invalid path must get a
342// generic refusal, never a page built out of attacker-controlled text.
343func Render(path string) string {
344 if path == "" {
345 return ufmt.Sprintf(
346 "# Hollowmere: Book One claims ledger\n\n"+
347 "This page records what players **claim** to have cleared, timestamped by the "+
348 "chain when they claimed it -- not what gno.land has verified. The chapters run "+
349 "in the browser, so this realm has no way to check that any chapter was actually "+
350 "played; see the package doc for why that is an honest trade, not an oversight.\n\n"+
351 "* **Addresses with at least one chapter claimed**: %d\n",
352 totalClaimants,
353 )
354 }
355
356 addr := address(path)
357 if !addr.IsValid() {
358 return "# Hollowmere: Book One claims ledger\n\nNo claim for an invalid address.\n"
359 }
360
361 highest, c1, c2, c3, c4, seeds, found := BookOneClaim(addr)
362 if !found {
363 return ufmt.Sprintf("# Hollowmere: Book One claims ledger\n\nNo chapter claimed cleared yet for `%s`.\n", addr.String())
364 }
365
366 str := ufmt.Sprintf("# Hollowmere: Book One -- %s's claimed progress\n\n", addr.String())
367 str += "This is a **claim**, timestamped by the chain when this wallet signed it -- not a verified record of play. See the package doc.\n\n"
368 str += ufmt.Sprintf("* **Chapters claimed cleared**: %d of %d\n", highest, numChaptersBookOne)
369 str += chapterLine(1, c1)
370 str += chapterLine(2, c2)
371 str += chapterLine(3, c3)
372 str += chapterLine(4, c4)
373 str += ufmt.Sprintf("* **Ember Seeds** (derived from chapters 1-3 claimed cleared; never claimable directly): %d\n", seeds)
374 return str
375}
376
377func chapterLine(chapter, claimedAt int64) string {
378 if claimedAt == 0 {
379 return ufmt.Sprintf("* **Chapter %d**: not yet claimed\n", chapter)
380 }
381 return ufmt.Sprintf("* **Chapter %d**: claimed cleared at %d\n", chapter, claimedAt)
382}