1// Package streak is an on-chain check-in streak tracker: call CheckIn once per2// "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 of5// BlocksPerDay blocks: day = ChainHeight() / BlocksPerDay. That makes every6// 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 is10// 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 a13// lone realm rather than a p/ library + demo pair.14package streak1516import (17 "sort"18 "strconv"19 "strings"2021 "chain"22 "chain/runtime"2324 "gno.land/p/nt/avl/v0"25)2627// BlocksPerDay is the length of a check-in window, in blocks. Chosen so a28// window is a meaningful stretch of chain activity while keeping tests and29// demos quick to reason about.30const BlocksPerDay = 10003132// maxLeaderboard caps how many entries Render lists.33const maxLeaderboard = 103435// record is one address's streak state.36type record struct {37 current int // consecutive days up to lastDay38 best int // highest current ever reached; never lowered39 lastDay int64 // day index of the most recent check-in40 totalIn int // total check-ins ever41}4243// users maps address string -> *record.44var users = avl.NewTree()4546// today returns the current day index: block height divided into fixed windows.47func today() int64 { return runtime.ChainHeight() / BlocksPerDay }4849// 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 when52// the window does. A gap of exactly one window continues the streak; any larger53// 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()6465 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 below75 default:76 // missed at least one window: the run is broken77 r.current = 078 }79 }8081 r.current++82 r.lastDay = day83 r.totalIn++84 if r.current > r.best {85 r.best = r.current86 }8788 chain.Emit("CheckIn",89 "addr", addr.String(),90 "day", strconv.FormatInt(day, 10),91 "streak", strconv.Itoa(r.current),92 )93 return r.current94}9596// 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), so99// 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 nil104 }105 return v.(*record)106}107108// Current returns addr's live streak — 0 once a window has been missed, even109// 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 0114 }115 day := today()116 if r.lastDay != day && r.lastDay != day-1 {117 return 0118 }119 return r.current120}121122// 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 0127 }128 return r.best129}130131// Day returns the current day index.132func Day() int64 { return today() }133134// entry is a flattened record for rendering.135type entry struct {136 addr string137 current int138 best int139 total int140}141142// byBest ranks entries by best streak descending, then by address so equal143// scores always come out in the same order (Render must be deterministic).144type byBest []entry145146func (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].best151 }152 return e[i].addr < e[j].addr153}154155// Render renders the streak board for gnoweb.156//157// Render("") / Render("/") -> leaderboard by best streak158// Render("/<address>") -> that address's standing159func Render(path string) string {160 var b strings.Builder161 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")168169 if q := parseArg(path); q != "" {170 return b.String() + renderOne(q)171 }172173 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 }179180 // gno's sort has Sort(Interface) but no Slice(), so ranking goes through an181 // 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 }186187 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}203204// renderOne renders a single address's standing.205func renderOne(q string) string {206 addr := address(q)207 var b strings.Builder208 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}231232// 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 false244 })245 return out246}247248// 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 s256}257Best(addr string) int
CheckIn(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}) int
Current(addr string) int
Day() 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.