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