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.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}