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

streak.gno

6.82 Kb · 256 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	b.WriteString("## `")
209	b.WriteString(q)
210	b.WriteString("`\n\n")
211	if !addr.IsValid() {
212		b.WriteString("_Not a valid address._\n")
213		return b.String()
214	}
215	r := get(addr)
216	if r == nil {
217		b.WriteString("_No check-ins yet._\n")
218		return b.String()
219	}
220	b.WriteString("- current streak: **")
221	b.WriteString(strconv.Itoa(Current(addr)))
222	b.WriteString("**\n- best streak: **")
223	b.WriteString(strconv.Itoa(r.best))
224	b.WriteString("**\n- total check-ins: **")
225	b.WriteString(strconv.Itoa(r.totalIn))
226	b.WriteString("**\n- last check-in: day **")
227	b.WriteString(strconv.FormatInt(r.lastDay, 10))
228	b.WriteString("**\n")
229	return b.String()
230}
231
232// all flattens the tree into a slice.
233func all() []entry {
234	out := []entry{}
235	users.Iterate("", "", func(k string, v any) bool {
236		r := v.(*record)
237		out = append(out, entry{
238			addr:    k,
239			current: Current(address(k)),
240			best:    r.best,
241			total:   r.totalIn,
242		})
243		return false
244	})
245	return out
246}
247
248// parseArg extracts the first path segment.
249func parseArg(path string) string {
250	s := strings.TrimSpace(path)
251	s = strings.TrimPrefix(s, "/")
252	if i := strings.IndexByte(s, '/'); i >= 0 {
253		s = s[:i]
254	}
255	return s
256}