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