polls.gno
6.99 Kb · 271 lines
1// Package polls is an on-chain poll / voting realm for gno.land.
2//
3// Anyone can create a poll with a question and a comma-separated list of
4// options. Each caller address may cast exactly one vote per poll. Render
5// draws every poll with its options, tallies as ▓░ bar charts, percentages
6// and the total vote count.
7package polls
8
9import (
10 "chain"
11 "chain/markdown"
12 "strconv"
13 "strings"
14
15 "gno.land/p/moul/kit/ui/v0"
16 "gno.land/p/nt/avl/v0"
17)
18
19// selfPath is this realm's own page. A root-relative link resolves against
20// the domain, not the realm, so every link back into it names it in full.
21const selfPath = "/r/moul/x/daily/polls/v1"
22
23// Poll is a single question with its options and per-address vote tracking.
24type Poll struct {
25 ID int
26 Question string
27 Options []string
28 Tallies []int
29 Creator string // bech32 address of the poll creator
30 voted *avl.Tree // address string -> struct{}{}, one vote per address
31}
32
33var (
34 polls []*Poll // index i holds the poll with ID i
35 nextID int
36)
37
38// CreatePoll registers a new poll. options is a comma-separated list; blank
39// entries are dropped. Returns the new poll's id. Crossing function: an EOA
40// invokes it via MsgCall; another realm via polls.CreatePoll(cross, q, opts).
41func CreatePoll(cur realm, question string, options string) int {
42 creator := caller(cur).String()
43
44 q := strings.TrimSpace(question)
45 if strings.TrimSpace(markdown.StripBidiAndZeroWidth(q)) == "" {
46 panic("question must not be empty")
47 }
48
49 if len(q) > MaxQuestionLen {
50 panic("question too long")
51 }
52 opts := parseOptions(options)
53 if len(opts) < 2 {
54 panic("a poll needs at least 2 options")
55 }
56 if len(opts) > MaxOptions {
57 panic("a poll takes at most " + strconv.Itoa(MaxOptions) + " options")
58 }
59 for _, o := range opts {
60 if len(o) > MaxOptionLen {
61 panic("option too long")
62 }
63 }
64 if len(polls) >= MaxPolls {
65 panic("polls is full at " + strconv.Itoa(MaxPolls) + " polls")
66 }
67
68 id := nextID
69 nextID++
70 p := &Poll{
71 ID: id,
72 Question: q,
73 Options: opts,
74 Tallies: make([]int, len(opts)),
75 Creator: creator,
76 voted: avl.NewTree(),
77 }
78 polls = append(polls, p)
79
80 chain.Emit("PollCreated",
81 "id", strconv.Itoa(id),
82 "creator", creator,
83 "options", strconv.Itoa(len(opts)),
84 )
85 return id
86}
87
88// Vote casts one vote for optionIndex on poll pollID. Each caller address may
89// vote at most once per poll. Crossing function.
90func Vote(cur realm, pollID int, optionIndex int) {
91 voter := caller(cur).String()
92
93 p := getPoll(pollID)
94 if optionIndex < 0 || optionIndex >= len(p.Options) {
95 panic("option index out of range")
96 }
97 if p.voted.Has(voter) {
98 panic("address already voted on this poll")
99 }
100
101 p.voted.Set(voter, struct{}{})
102 p.Tallies[optionIndex]++
103
104 chain.Emit("Voted",
105 "id", strconv.Itoa(pollID),
106 "voter", voter,
107 "option", strconv.Itoa(optionIndex),
108 )
109}
110
111// parseOptions splits a comma-separated option string, trimming whitespace and
112// dropping empties. Pure logic — no realm state.
113func parseOptions(options string) []string {
114 var out []string
115 for _, raw := range strings.Split(options, ",") {
116 o := strings.TrimSpace(raw)
117 if strings.TrimSpace(markdown.StripBidiAndZeroWidth(o)) != "" {
118 out = append(out, o)
119 }
120 }
121 return out
122}
123
124// getPoll returns the poll with the given id or panics if it does not exist.
125func getPoll(id int) *Poll {
126 if id < 0 || id >= len(polls) {
127 panic("poll not found")
128 }
129 return polls[id]
130}
131
132// PollCount returns the number of polls created so far (read-only helper).
133func PollCount() int {
134 return len(polls)
135}
136
137const barWidth = 20
138
139// The bounds on what a creator may store. Anyone may create a poll, and the
140// root page renders the newest ones.
141const (
142 MaxPolls = 500
143 MaxQuestionLen = 200
144 MaxOptions = 10
145 MaxOptionLen = 64
146 IndexRecent = 20 // polls the root page shows, newest first
147)
148
149// bar renders a proportional ▓░ bar of fixed width for count out of total.
150func bar(count, total int) string {
151 filled := 0
152 if total > 0 {
153 filled = count * barWidth / total
154 }
155 if filled > barWidth {
156 filled = barWidth
157 }
158 return strings.Repeat("▓", filled) + strings.Repeat("░", barWidth-filled)
159}
160
161// percent returns the integer percentage of count out of total.
162func percent(count, total int) int {
163 if total == 0 {
164 return 0
165 }
166 return count * 100 / total
167}
168
169// total sums a tally slice.
170func total(tallies []int) int {
171 sum := 0
172 for _, t := range tallies {
173 sum += t
174 }
175 return sum
176}
177
178// renderPoll writes one poll's Markdown block into b.
179func renderPoll(b *strings.Builder, p *Poll) {
180 sum := total(p.Tallies)
181
182 b.WriteString("## Poll #")
183 b.WriteString(strconv.Itoa(p.ID))
184 b.WriteString(": ")
185 b.WriteString(ui.Inline(p.Question))
186 b.WriteString("\n\n")
187
188 for i, opt := range p.Options {
189 c := p.Tallies[i]
190 b.WriteString("- **")
191 b.WriteString(ui.Inline(opt))
192 b.WriteString("** `")
193 b.WriteString(bar(c, sum))
194 b.WriteString("` ")
195 b.WriteString(strconv.Itoa(percent(c, sum)))
196 b.WriteString("% (")
197 b.WriteString(strconv.Itoa(c))
198 if c == 1 {
199 b.WriteString(" vote)")
200 } else {
201 b.WriteString(" votes)")
202 }
203 b.WriteString("\n")
204 }
205
206 b.WriteString("\n_Total votes: ")
207 b.WriteString(strconv.Itoa(sum))
208 b.WriteString("_\n\n")
209}
210
211// Render produces the Markdown page for gnoweb. It is NOT a crossing function.
212//
213// - "" -> the IndexRecent newest polls
214// - "page/<n>" -> the n-th page of IndexRecent, newest first (page/1 is "")
215// - "<id>" -> a single poll by id
216func Render(path string) string {
217 var b strings.Builder
218
219 path = strings.TrimSpace(path)
220 page := 1
221 if rest, ok := strings.CutPrefix(path, "page/"); ok {
222 n, err := strconv.Atoi(rest)
223 // Bounded before any multiply: the page comes from the URL.
224 if err != nil || n < 1 || n > 1+(len(polls)-1)/IndexRecent {
225 return "# Polls\n\nNo such page.\n\n[← all polls](" + selfPath + ")\n"
226 }
227 page, path = n, ""
228 }
229 if path != "" {
230 id, err := strconv.Atoi(path)
231 if err != nil || id < 0 || id >= len(polls) {
232 return "# Polls\n\nPoll not found.\n"
233 }
234 b.WriteString("# Polls\n\n")
235 renderPoll(&b, polls[id])
236 b.WriteString("[← all polls](" + selfPath + ")\n")
237 return b.String()
238 }
239
240 b.WriteString("# Polls & Voting\n\n")
241 if len(polls) == 0 {
242 b.WriteString("_No polls yet. Create one with `CreatePoll(question, \"a,b,c\")`._\n")
243 return b.String()
244 }
245
246 pages := 1 + (len(polls)-1)/IndexRecent
247 b.WriteString("Total polls: ")
248 b.WriteString(strconv.Itoa(len(polls)))
249 if pages > 1 {
250 b.WriteString(", page " + strconv.Itoa(page) + " of " + strconv.Itoa(pages))
251 }
252 b.WriteString("\n\n")
253 //gnovet:ignore page-offset-overflow page is bounded by pages above
254 top := len(polls) - 1 - (page-1)*IndexRecent
255 for i := top; i >= 0 && i > top-IndexRecent; i-- {
256 renderPoll(&b, polls[i])
257 }
258 if page < pages {
259 b.WriteString("[older polls →](" + selfPath + ":page/" + strconv.Itoa(page+1) + ")\n")
260 }
261 return b.String()
262}
263
264// caller is the address that crossed into this realm, after checking that the
265// realm token is this frame's own.
266func caller(cur realm) address {
267 if !cur.IsCurrent() {
268 panic("polls: spoofed realm")
269 }
270 return cur.Previous().Address()
271}