reads.gno
6.22 Kb · 201 lines
1package memba_arcade_leaderboard_v1
2
3import (
4 "strings"
5
6 "gno.land/p/nt/ufmt/v0"
7)
8
9// ── Read getters (pure, non-failing; served via vm/qeval or Render) ─────────────
10
11// GetOwner returns the current owner.
12func GetOwner() address { return owner }
13
14// IsPaused reports whether attestation is frozen.
15func IsPaused() bool { return paused }
16
17// IsAttester reports whether an address is on the attester allowlist.
18func IsAttester(addr string) bool { return isAttester(addr) }
19
20// BoardCount returns how many addresses hold an entry on a game's board for a day (O(1)).
21func BoardCount(game, day string) int {
22 if v, ok := boardCount.Get(game + "|" + day); ok {
23 return v.(int)
24 }
25 return 0
26}
27
28// entryJSON emits one entry. `stats` is the attester's opaque JSON blob and is emitted as an
29// ESCAPED STRING FIELD — never spliced raw into the document — so a malformed blob can corrupt
30// nothing but itself.
31func entryJSON(e *Entry) string {
32 return ufmt.Sprintf(
33 `{"addr":"%s","game":"%s","day":"%s","mode":"%s","score":%d,"stats":"%s","simVersion":%d,"stateHash":"%s","inputLogSha256":"%s","attestedAt":%d}`,
34 jsonEscape(e.Addr.String()), jsonEscape(e.Game), jsonEscape(e.Day), jsonEscape(e.Mode),
35 e.Score, jsonEscape(e.Stats), e.SimVersion, jsonEscape(e.StateHash),
36 jsonEscape(e.InputLogSha256), e.AttestedAt)
37}
38
39// GetEntryJSON returns one address's board entry for a game-day, or "null".
40func GetEntryJSON(game, day, addr string) string {
41 if v, ok := boards.Get(game + "|" + day + "|" + addr); ok {
42 return entryJSON(v.(*Entry))
43 }
44 return "null"
45}
46
47// dayEntries collects a game-day's board entries in canonical rank order: score desc,
48// then earlier attestation, then address — a total order, so pagination is stable.
49func dayEntries(game, day string) []*Entry {
50 var out []*Entry
51 prefix := game + "|" + day + "|"
52 boards.Iterate(prefix, prefix+"~", func(_ string, v any) bool {
53 out = append(out, v.(*Entry))
54 return false
55 })
56 // Insertion sort (gno stdlib has no sort.Slice): a day's board is bounded
57 // and reads are qeval-side — O(n²) on a few hundred entries is fine.
58 for i := 1; i < len(out); i++ {
59 e := out[i]
60 j := i - 1
61 for j >= 0 && ranksAfter(out[j], e) {
62 out[j+1] = out[j]
63 j--
64 }
65 out[j+1] = e
66 }
67 return out
68}
69
70// ranksAfter reports whether a should sit AFTER b in rank order — a total
71// order (score desc, then earlier attestation, then address) so pagination
72// is stable and identical everywhere.
73func ranksAfter(a, b *Entry) bool {
74 if a.Score != b.Score {
75 return a.Score < b.Score
76 }
77 if a.AttestedAt != b.AttestedAt {
78 return a.AttestedAt > b.AttestedAt
79 }
80 return a.Addr.String() > b.Addr.String()
81}
82
83// GetBoardJSON returns a page of a game-day's board, rank-ordered, as
84// {"game":…,"day":…,"total":N,"entries":[…]}. Reads sort the day's entries on demand —
85// fine for a testnet-scale board; a rank index (points_v1-style) is the v2
86// upgrade if daily boards grow past a few thousand entries.
87func GetBoardJSON(game, day string, offset, limit int) string {
88 all := dayEntries(game, day)
89 if offset < 0 {
90 offset = 0
91 }
92 if limit <= 0 || limit > 100 {
93 limit = 100
94 }
95 var sb strings.Builder
96 sb.WriteString(ufmt.Sprintf(`{"game":"%s","day":"%s","total":%d,"entries":[`,
97 jsonEscape(game), jsonEscape(day), len(all)))
98 for i := offset; i < len(all) && i < offset+limit; i++ {
99 if i > offset {
100 sb.WriteString(",")
101 }
102 sb.WriteString(entryJSON(all[i]))
103 }
104 sb.WriteString("]}")
105 return sb.String()
106}
107
108// GetReceiptsJSON returns a page of one address's training receipts (all games, newest first) as
109// {"addr":…,"total":N,"entries":[…]}.
110func GetReceiptsJSON(addr string, offset, limit int) string {
111 var ring []*Entry
112 if v, ok := receipts.Get(addr); ok {
113 ring = v.([]*Entry)
114 }
115 if offset < 0 {
116 offset = 0
117 }
118 if limit <= 0 || limit > 100 {
119 limit = 100
120 }
121 var sb strings.Builder
122 sb.WriteString(ufmt.Sprintf(`{"addr":"%s","total":%d,"entries":[`, jsonEscape(addr), len(ring)))
123 n := 0
124 for i := len(ring) - 1 - offset; i >= 0 && n < limit; i-- {
125 if n > 0 {
126 sb.WriteString(",")
127 }
128 sb.WriteString(entryJSON(ring[i]))
129 n++
130 }
131 sb.WriteString("]}")
132 return sb.String()
133}
134
135// Render serves a human-readable snapshot: `:board/<game>/YYYY-MM-DD` for a game-day's top
136// entries, `:receipts/<addr>` for an address's latest receipts.
137func Render(path string) string {
138 if strings.HasPrefix(path, "board/") {
139 rest := path[len("board/"):]
140 slash := strings.Index(rest, "/")
141 if slash < 0 {
142 return "# memba_arcade_leaderboard_v1\n\nUse `:board/<game>/YYYY-MM-DD`.\n"
143 }
144 game := rest[:slash]
145 day := rest[slash+1:]
146 all := dayEntries(game, day)
147 var sb strings.Builder
148 sb.WriteString("# " + jsonEscape(game) + " daily board — " + jsonEscape(day) + "\n\n")
149 if len(all) == 0 {
150 sb.WriteString("No attested runs yet.\n")
151 return sb.String()
152 }
153 for i, e := range all {
154 if i >= 50 {
155 sb.WriteString(ufmt.Sprintf("\n…and %d more.\n", len(all)-50))
156 break
157 }
158 line := ufmt.Sprintf("%d. `%s` — **%d**", i+1, e.Addr.String(), e.Score)
159 if e.Stats != "" {
160 line += " · " + jsonEscape(e.Stats)
161 }
162 sb.WriteString(line + "\n")
163 }
164 return sb.String()
165 }
166 if strings.HasPrefix(path, "receipts/") {
167 addr := path[len("receipts/"):]
168 return "# Training receipts — " + addr + "\n\n```json\n" + GetReceiptsJSON(addr, 0, 20) + "\n```\n"
169 }
170 return "# memba_arcade_leaderboard_v1\n\nVerified Memba arcade results — never a client-claimed number.\n\n" +
171 "- `:board/<game>/YYYY-MM-DD` — a game's competitive board for a day\n" +
172 "- `:receipts/<addr>` — an address's training receipts (all games)\n"
173}
174
175// jsonEscape escapes a string for embedding in a JSON string literal (defensive — addresses are
176// bech32 and hashes are hex, but the read path escapes regardless; the Render path reuses it to
177// neutralize newlines/control chars in attester-authored stats).
178func jsonEscape(s string) string {
179 var sb strings.Builder
180 for _, r := range s {
181 switch r {
182 case '"':
183 sb.WriteString("\\\"")
184 case '\\':
185 sb.WriteString("\\\\")
186 case '\n':
187 sb.WriteString("\\n")
188 case '\r':
189 sb.WriteString("\\r")
190 case '\t':
191 sb.WriteString("\\t")
192 default:
193 if r < 0x20 {
194 sb.WriteString(ufmt.Sprintf("\\u%04x", r))
195 } else {
196 sb.WriteString(string(r))
197 }
198 }
199 }
200 return sb.String()
201}