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.

streak.gno

6.97 Kb · 258 lines
  1// Package streak is an on-chain check-in streak tracker: call CheckIn once per
  2// "day" to keep a streak alive, miss one and it resets to 1.
  3//
  4// There is no wall clock on-chain, so a "day" here is a fixed window of
  5// BlocksPerDay blocks: day = ChainHeight() / BlocksPerDay. That makes every
  6// streak decision a pure function of block height — deterministic, replayable,
  7// and impossible to game by waiting for a favourable timestamp.
  8//
  9// State is per-caller and append-only in spirit: an address's best streak is
 10// never lowered, so breaking a run costs the current streak but not the record.
 11//
 12// This is a stateful app with nothing reusable to extract, so it ships as a
 13// lone realm rather than a p/ library + demo pair.
 14package streak
 15
 16import (
 17	"sort"
 18	"strconv"
 19	"strings"
 20
 21	"chain"
 22	"chain/runtime"
 23
 24	"gno.land/p/nt/avl/v0"
 25)
 26
 27// BlocksPerDay is the length of a check-in window, in blocks. Chosen so a
 28// window is a meaningful stretch of chain activity while keeping tests and
 29// demos quick to reason about.
 30const BlocksPerDay = 1000
 31
 32// maxLeaderboard caps how many entries Render lists.
 33const maxLeaderboard = 10
 34
 35// record is one address's streak state.
 36type record struct {
 37	current int   // consecutive days up to lastDay
 38	best    int   // highest current ever reached; never lowered
 39	lastDay int64 // day index of the most recent check-in
 40	totalIn int   // total check-ins ever
 41}
 42
 43// users maps address string -> *record.
 44var users = avl.NewTree()
 45
 46// today returns the current day index: block height divided into fixed windows.
 47func today() int64 { return runtime.ChainHeight() / BlocksPerDay }
 48
 49// CheckIn records a check-in for the caller and returns the resulting streak.
 50//
 51// Checking in twice in the same window is rejected — the streak only moves when
 52// the window does. A gap of exactly one window continues the streak; any larger
 53// gap restarts it at 1.
 54func CheckIn(cur realm) int {
 55	if !cur.IsCurrent() {
 56		panic("spoofed realm")
 57	}
 58	prev := cur.Previous()
 59	if !prev.IsUserCall() {
 60		panic("only an EOA via MsgCall can check in")
 61	}
 62	addr := prev.Address()
 63	day := today()
 64
 65	r := get(addr)
 66	if r == nil {
 67		r = &record{}
 68		users.Set(addr.String(), r)
 69	} else {
 70		switch {
 71		case r.totalIn > 0 && r.lastDay == day:
 72			panic("already checked in for day " + strconv.FormatInt(day, 10))
 73		case r.totalIn > 0 && r.lastDay == day-1:
 74			// consecutive window: the streak continues below
 75		default:
 76			// missed at least one window: the run is broken
 77			r.current = 0
 78		}
 79	}
 80
 81	r.current++
 82	r.lastDay = day
 83	r.totalIn++
 84	if r.current > r.best {
 85		r.best = r.current
 86	}
 87
 88	chain.Emit("CheckIn",
 89		"addr", addr.String(),
 90		"day", strconv.FormatInt(day, 10),
 91		"streak", strconv.Itoa(r.current),
 92	)
 93	return r.current
 94}
 95
 96// get returns the caller's record, or nil.
 97//
 98// avl/v0's Get returns a single `any` (not the value, ok pair Go maps use), so
 99// a missing key surfaces as a nil interface rather than a second return value.
100func get(addr address) *record {
101	v := users.Get(addr.String())
102	if v == nil {
103		return nil
104	}
105	return v.(*record)
106}
107
108// Current returns addr's live streak — 0 once a window has been missed, even
109// though the stored value only updates on the next check-in.
110func Current(addr address) int {
111	r := get(addr)
112	if r == nil {
113		return 0
114	}
115	day := today()
116	if r.lastDay != day && r.lastDay != day-1 {
117		return 0
118	}
119	return r.current
120}
121
122// Best returns addr's record streak, which is never lowered.
123func Best(addr address) int {
124	r := get(addr)
125	if r == nil {
126		return 0
127	}
128	return r.best
129}
130
131// Day returns the current day index.
132func Day() int64 { return today() }
133
134// entry is a flattened record for rendering.
135type entry struct {
136	addr    string
137	current int
138	best    int
139	total   int
140}
141
142// byBest ranks entries by best streak descending, then by address so equal
143// scores always come out in the same order (Render must be deterministic).
144type byBest []entry
145
146func (e byBest) Len() int      { return len(e) }
147func (e byBest) Swap(i, j int) { e[i], e[j] = e[j], e[i] }
148func (e byBest) Less(i, j int) bool {
149	if e[i].best != e[j].best {
150		return e[i].best > e[j].best
151	}
152	return e[i].addr < e[j].addr
153}
154
155// Render renders the streak board for gnoweb.
156//
157//	Render("")          / Render("/") -> leaderboard by best streak
158//	Render("/<address>")             -> that address's standing
159func Render(path string) string {
160	var b strings.Builder
161	b.WriteString("# Check-in Streaks\n\n")
162	b.WriteString("A \"day\" is ")
163	b.WriteString(strconv.Itoa(BlocksPerDay))
164	b.WriteString(" blocks — there is no clock on-chain, so streaks are decided by block height alone.\n\n")
165	b.WriteString("Current day: **")
166	b.WriteString(strconv.FormatInt(today(), 10))
167	b.WriteString("**\n\n")
168
169	if q := parseArg(path); q != "" {
170		return b.String() + renderOne(q)
171	}
172
173	entries := all()
174	if len(entries) == 0 {
175		b.WriteString("_Nobody has checked in yet._\n\n")
176		b.WriteString("> Call `CheckIn()` to start a streak.\n")
177		return b.String()
178	}
179
180	// gno's sort has Sort(Interface) but no Slice(), so ranking goes through an
181	// explicit sort.Interface. Ties break on address so the board is stable.
182	sort.Sort(byBest(entries))
183	if len(entries) > maxLeaderboard {
184		entries = entries[:maxLeaderboard]
185	}
186
187	b.WriteString("| # | address | current | best | check-ins |\n|---|---|---|---|---|\n")
188	for i, e := range entries {
189		b.WriteString("| ")
190		b.WriteString(strconv.Itoa(i + 1))
191		b.WriteString(" | `")
192		b.WriteString(e.addr)
193		b.WriteString("` | ")
194		b.WriteString(strconv.Itoa(e.current))
195		b.WriteString(" | ")
196		b.WriteString(strconv.Itoa(e.best))
197		b.WriteString(" | ")
198		b.WriteString(strconv.Itoa(e.total))
199		b.WriteString(" |\n")
200	}
201	return b.String()
202}
203
204// renderOne renders a single address's standing.
205func renderOne(q string) string {
206	addr := address(q)
207	var b strings.Builder
208	// Validate before echoing: a valid address is bech32, which cannot carry
209	// markdown, and an invalid one is never written back to the page.
210	if !addr.IsValid() {
211		b.WriteString("_Not a valid address._\n")
212		return b.String()
213	}
214	b.WriteString("## `")
215	b.WriteString(addr.String())
216	b.WriteString("`\n\n")
217	r := get(addr)
218	if r == nil {
219		b.WriteString("_No check-ins yet._\n")
220		return b.String()
221	}
222	b.WriteString("- current streak: **")
223	b.WriteString(strconv.Itoa(Current(addr)))
224	b.WriteString("**\n- best streak: **")
225	b.WriteString(strconv.Itoa(r.best))
226	b.WriteString("**\n- total check-ins: **")
227	b.WriteString(strconv.Itoa(r.totalIn))
228	b.WriteString("**\n- last check-in: day **")
229	b.WriteString(strconv.FormatInt(r.lastDay, 10))
230	b.WriteString("**\n")
231	return b.String()
232}
233
234// all flattens the tree into a slice.
235func all() []entry {
236	out := []entry{}
237	users.Iterate("", "", func(k string, v any) bool {
238		r := v.(*record)
239		out = append(out, entry{
240			addr:    k,
241			current: Current(address(k)),
242			best:    r.best,
243			total:   r.totalIn,
244		})
245		return false
246	})
247	return out
248}
249
250// parseArg extracts the first path segment.
251func parseArg(path string) string {
252	s := strings.TrimSpace(path)
253	s = strings.TrimPrefix(s, "/")
254	if i := strings.IndexByte(s, '/'); i >= 0 {
255		s = s[:i]
256	}
257	return s
258}