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

streaks.gno

5.17 Kb Β· 182 lines
  1// Package streaks is an on-chain habit-streak tracker. Call CheckIn() again
  2// within graceBlocks of your last check-in and the streak keeps growing; let
  3// the gap run past that and it resets to zero on your next check-in. Gno has
  4// no wall clock, so "again soon enough" is measured in blocks rather than
  5// calendar days β€” graceBlocks is the tunable stand-in for "before tomorrow".
  6package streaks
  7
  8import (
  9	"sort"
 10	"strconv"
 11
 12	"chain/runtime"
 13
 14	"gno.land/p/nt/avl/v0"
 15)
 16
 17// graceBlocks is the max block gap between two check-ins that still counts
 18// as consecutive. A gap larger than this lapses the streak.
 19const graceBlocks int64 = 100
 20
 21// record is the persisted streak state for one address.
 22type record struct {
 23	owner      address
 24	current    int64
 25	longest    int64
 26	total      int64
 27	lastHeight int64
 28}
 29
 30var streaks avl.Tree // owner address string -> *record
 31
 32func get(owner address) (*record, bool) {
 33	v, ok := streaks.Get(owner.String()).(*record)
 34	return v, ok
 35}
 36
 37// project reports the record's current streak as of height without
 38// mutating it: once the gap since lastHeight exceeds graceBlocks the streak
 39// reads as lapsed, even though the stored value only resets on the next
 40// actual check-in.
 41func project(r *record, height int64) (current int64, alive bool) {
 42	if height-r.lastHeight > graceBlocks {
 43		return 0, false
 44	}
 45	return r.current, true
 46}
 47
 48// checkin is the non-crossing core of CheckIn, kept separate so unit tests
 49// can drive it directly by address and height.
 50func checkin(owner address, height int64) string {
 51	r, ok := get(owner)
 52	if !ok {
 53		r = &record{owner: owner}
 54		streaks.Set(owner.String(), r)
 55	} else {
 56		if height <= r.lastHeight {
 57			panic("already checked in at this block height")
 58		}
 59		if height-r.lastHeight > graceBlocks {
 60			r.current = 0
 61		}
 62	}
 63	r.current++
 64	if r.current > r.longest {
 65		r.longest = r.current
 66	}
 67	r.lastHeight = height
 68	r.total++
 69	return "checked in β€” current streak: " + strconv.FormatInt(r.current, 10) + " πŸ”₯"
 70}
 71
 72// CheckIn records a check-in for the caller at the current block height.
 73func CheckIn(cur realm) string {
 74	if !cur.IsCurrent() {
 75		panic("spoofed realm")
 76	}
 77	return checkin(cur.Previous().Address(), runtime.ChainHeight())
 78}
 79
 80// CurrentStreak returns addr's live current streak, 0 if it has lapsed or
 81// addr has never checked in.
 82func CurrentStreak(addr string) int64 {
 83	r, ok := get(address(addr))
 84	if !ok {
 85		return 0
 86	}
 87	current, _ := project(r, runtime.ChainHeight())
 88	return current
 89}
 90
 91// LongestStreak returns addr's best streak ever, 0 if it has never checked in.
 92func LongestStreak(addr string) int64 {
 93	r, ok := get(address(addr))
 94	if !ok {
 95		return 0
 96	}
 97	return r.longest
 98}
 99
100// byRank orders the leaderboard by longest streak, then current streak, then
101// owner address β€” fully deterministic regardless of avl iteration order.
102type byRank []*record
103
104func (b byRank) Len() int      { return len(b) }
105func (b byRank) Swap(i, j int) { b[i], b[j] = b[j], b[i] }
106func (b byRank) Less(i, j int) bool {
107	if b[i].longest != b[j].longest {
108		return b[i].longest > b[j].longest
109	}
110	if b[i].current != b[j].current {
111		return b[i].current > b[j].current
112	}
113	return b[i].owner.String() < b[j].owner.String()
114}
115
116func shortAddr(a address) string {
117	s := a.String()
118	if len(s) > 12 {
119		return s[:8] + "…" + s[len(s)-4:]
120	}
121	return s
122}
123
124func renderDetail(r *record, height int64) string {
125	current, alive := project(r, height)
126	status := "πŸ”₯ active"
127	if !alive {
128		status = "πŸ’€ lapsed"
129	}
130	out := "## `" + shortAddr(r.owner) + "`\n\n"
131	out += "- Status: " + status + "\n"
132	out += "- Current streak: " + strconv.FormatInt(current, 10) + "\n"
133	out += "- Longest streak: " + strconv.FormatInt(r.longest, 10) + "\n"
134	out += "- Total check-ins: " + strconv.FormatInt(r.total, 10) + "\n"
135	out += "- Last check-in at block " + strconv.FormatInt(r.lastHeight, 10) + "\n"
136	return out
137}
138
139// Render shows either the full leaderboard (path == "") or a single
140// address's detail card when path is a bech32 address.
141func Render(path string) string {
142	height := runtime.ChainHeight()
143
144	out := "# πŸ”₯ Streaks\n\n"
145	out += "An on-chain habit-streak tracker. Call `CheckIn()` again within " +
146		strconv.FormatInt(graceBlocks, 10) + " blocks of your last check-in to keep it alive; " +
147		"wait longer than that and it resets to zero on your next check-in.\n\n"
148
149	if path != "" {
150		r, ok := get(address(path))
151		if !ok {
152			return out + "_No check-ins yet for `" + path + "`._\n"
153		}
154		return out + renderDetail(r, height)
155	}
156
157	var rows []*record
158	streaks.Iterate("", "", func(_ string, v any) bool {
159		rows = append(rows, v.(*record))
160		return false
161	})
162	if len(rows) == 0 {
163		out += "_Nobody has checked in yet. Be the first!_\n"
164		return out
165	}
166	sort.Stable(byRank(rows))
167
168	out += "| Rank | Address | Status | Current | Longest | Total |\n"
169	out += "| ---: | :--- | :--- | ---: | ---: | ---: |\n"
170	for i, r := range rows {
171		current, alive := project(r, height)
172		status := "πŸ”₯"
173		if !alive {
174			status = "πŸ’€"
175		}
176		out += "| " + strconv.Itoa(i+1) + " | `" + shortAddr(r.owner) + "` | " + status +
177			" | " + strconv.FormatInt(current, 10) + " | " + strconv.FormatInt(r.longest, 10) +
178			" | " + strconv.FormatInt(r.total, 10) + " |\n"
179	}
180	out += "\n_View a single address at `?<address>`._\n"
181	return out
182}