leaderboard.gno
3.60 Kb · 135 lines
1// Package leaderboard is an on-chain score leaderboard realm.
2//
3// Any account can accumulate points via AddPoints and (optionally) attach a
4// display name with SetName. Render produces a ranked Markdown table sorted by
5// points descending, with medals for the top three players.
6package leaderboard
7
8import (
9 "sort"
10 "strconv"
11
12 "chain/runtime/unsafe"
13
14 "gno.land/p/moul/kit/ui/v0"
15 "gno.land/p/nt/avl/v0"
16)
17
18// player holds the accumulated state for a single address.
19type player struct {
20 addr address
21 name string
22 points int
23}
24
25// players maps address string -> *player. An avl.Tree gives deterministic
26// iteration (unlike a Go map) so Render output is stable across nodes.
27var players avl.Tree
28
29// get returns the stored *player for addr, creating one if absent.
30func get(addr address) *player {
31 key := addr.String()
32 if v := players.Get(key); v != nil {
33 return v.(*player)
34 }
35 p := &player{addr: addr}
36 players.Set(key, p)
37 return p
38}
39
40// caller returns the address of the account that invoked the current tx.
41// unsafe.PreviousRealm() is the origin user for a MsgCall entry point.
42func caller() address {
43 return unsafe.PreviousRealm().Address()
44}
45
46// AddPoints adds n points to the caller's total. n must be positive.
47//
48// Crossing function: MsgCall dispatches only to crossing functions, and the
49// cur.IsCurrent() check authenticates the live call frame before we trust the
50// caller identity derived from it.
51func AddPoints(cur realm, n int) {
52 if !cur.IsCurrent() {
53 panic("spoofed realm")
54 }
55 if n <= 0 {
56 panic("points must be positive")
57 }
58 p := get(caller())
59 p.points += n
60}
61
62// SetName attaches a display name (max 32 bytes) to the caller. Passing an
63// empty string clears the name and falls back to the address in Render.
64func SetName(cur realm, name string) {
65 if !cur.IsCurrent() {
66 panic("spoofed realm")
67 }
68 if len(name) > 32 {
69 panic("name too long (max 32)")
70 }
71 get(caller()).name = name
72}
73
74// display returns the player's name, or a shortened address if unnamed.
75func (p *player) display() string {
76 if p.name != "" {
77 return p.name
78 }
79 s := p.addr.String()
80 if len(s) > 12 {
81 return s[:8] + "…" + s[len(s)-4:]
82 }
83 return s
84}
85
86// byRank implements sort.Interface, ranking players by points descending with
87// ties broken by address so the order is deterministic across nodes. The gno
88// sort package exposes Sort/Stable over an Interface — there is no sort.Slice.
89type byRank []*player
90
91func (r byRank) Len() int { return len(r) }
92func (r byRank) Swap(i, j int) { r[i], r[j] = r[j], r[i] }
93func (r byRank) Less(i, j int) bool {
94 if r[i].points != r[j].points {
95 return r[i].points > r[j].points
96 }
97 return r[i].addr.String() < r[j].addr.String()
98}
99
100// snapshot collects all players into a slice ranked by points descending.
101func snapshot() []*player {
102 out := make([]*player, 0, players.Size())
103 players.Iterate("", "", func(_ string, v any) bool {
104 out = append(out, v.(*player))
105 return false
106 })
107 sort.Stable(byRank(out))
108 return out
109}
110
111// Render produces the Markdown leaderboard. It is NOT a crossing function
112// (read-only view, no cur realm parameter).
113func Render(path string) string {
114 ranked := snapshot()
115
116 out := "# 🏆 Leaderboard\n\n"
117 if len(ranked) == 0 {
118 out += "_No players yet. Call `AddPoints(n)` to get on the board._\n"
119 return out
120 }
121
122 out += "Total players: **" + strconv.Itoa(len(ranked)) + "**\n\n"
123 out += "| Rank | Player | Points |\n"
124 out += "| ---: | :--- | ---: |\n"
125 for i, p := range ranked {
126 rankCell := ui.Podium(i)
127 if rankCell == "" {
128 rankCell = strconv.Itoa(i + 1)
129 }
130 out += "| " + rankCell +
131 " | " + p.display() +
132 " | " + strconv.Itoa(p.points) + " |\n"
133 }
134 return out
135}