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.

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}