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}