1// Package streaks is an on-chain habit-streak tracker. Call CheckIn() again2// within graceBlocks of your last check-in and the streak keeps growing; let3// the gap run past that and it resets to zero on your next check-in. Gno has4// no wall clock, so "again soon enough" is measured in blocks rather than5// calendar days — graceBlocks is the tunable stand-in for "before tomorrow".6package streaks78import (9 "sort"10 "strconv"1112 "chain/runtime"1314 "gno.land/p/nt/avl/v0"15)1617// graceBlocks is the max block gap between two check-ins that still counts18// as consecutive. A gap larger than this lapses the streak.19const graceBlocks int64 = 1002021// record is the persisted streak state for one address.22type record struct {23 owner address24 current int6425 longest int6426 total int6427 lastHeight int6428}2930var streaks avl.Tree // owner address string -> *record3132func get(owner address) (*record, bool) {33 v, ok := streaks.Get(owner.String()).(*record)34 return v, ok35}3637// project reports the record's current streak as of height without38// mutating it: once the gap since lastHeight exceeds graceBlocks the streak39// reads as lapsed, even though the stored value only resets on the next40// actual check-in.41func project(r *record, height int64) (current int64, alive bool) {42 if height-r.lastHeight > graceBlocks {43 return 0, false44 }45 return r.current, true46}4748// checkin is the non-crossing core of CheckIn, kept separate so unit tests49// 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 = 061 }62 }63 r.current++64 if r.current > r.longest {65 r.longest = r.current66 }67 r.lastHeight = height68 r.total++69 return "checked in — current streak: " + strconv.FormatInt(r.current, 10) + " 🔥"70}7172// 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}7980// CurrentStreak returns addr's live current streak, 0 if it has lapsed or81// addr has never checked in.82func CurrentStreak(addr string) int64 {83 r, ok := get(address(addr))84 if !ok {85 return 086 }87 current, _ := project(r, runtime.ChainHeight())88 return current89}9091// 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 096 }97 return r.longest98}99100// byRank orders the leaderboard by longest streak, then current streak, then101// owner address — fully deterministic regardless of avl iteration order.102type byRank []*record103104func (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].longest109 }110 if b[i].current != b[j].current {111 return b[i].current > b[j].current112 }113 return b[i].owner.String() < b[j].owner.String()114}115116func shortAddr(a address) string {117 s := a.String()118 if len(s) > 12 {119 return s[:8] + "…" + s[len(s)-4:]120 }121 return s122}123124func 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 out137}138139// Render shows either the full leaderboard (path == "") or a single140// address's detail card when path is a bech32 address.141func Render(path string) string {142 height := runtime.ChainHeight()143144 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"148149 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 }156157 var rows []*record158 streaks.Iterate("", "", func(_ string, v any) bool {159 rows = append(rows, v.(*record))160 return false161 })162 if len(rows) == 0 {163 out += "_Nobody has checked in yet. Be the first!_\n"164 return out165 }166 sort.Stable(byRank(rows))167168 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 out182}183CheckIn(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}) string
CurrentStreak(addr string) int64
LongestStreak(addr string) int64
Render(path string) string
Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.
vm/qrender output, sanitized (docs/render-security.md) and displayed in an empty-sandbox iframe — scripts, forms and popups cannot run. Links stay inert in-preview; right-click to open.