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

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}