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

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}