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}