quizstreak.gno
6.70 Kb · 205 lines
1// Package quizstreak is a multiple-choice trivia quiz where every player
2// runs their own independent streak instead of racing others for a single
3// shared question.
4//
5// Each address gets its own current question, picked deterministically from
6// the address and the block height of its first answer so different players
7// don't all start on the same question. A correct answer extends the
8// player's streak and advances them to their next question; a wrong answer
9// breaks the streak back to zero but leaves the same question live so they
10// can retry. The realm tracks each player's best-ever streak on a global
11// leaderboard.
12package quizstreak
13
14import (
15 "strconv"
16 "strings"
17
18 "chain"
19 "chain/runtime"
20
21 "gno.land/p/nt/avl/v0"
22)
23
24// question is one fixed trivia entry.
25type question struct {
26 text string
27 choices [4]string
28 correctIdx int
29}
30
31// playerState is one address's progress through the question bank.
32type playerState struct {
33 qIdx int
34 streak int
35 best int
36 correct int
37 wrong int
38}
39
40var (
41 questions = []question{
42 {"What is the smallest prime number?", [4]string{"0", "1", "2", "3"}, 2},
43 {"Which gas does a plant primarily absorb for photosynthesis?", [4]string{"Oxygen", "Nitrogen", "Carbon dioxide", "Hydrogen"}, 2},
44 {"How many continents are there on Earth?", [4]string{"5", "6", "7", "8"}, 2},
45 {"In binary, what is 1 + 1?", [4]string{"2", "10", "11", "0"}, 1},
46 {"What does 'HTTP' stand for?", [4]string{"HyperText Transfer Protocol", "High Transfer Text Protocol", "Home Tool Transfer Protocol", "HyperText Transmission Path"}, 0},
47 {"Which planet is known as the Red Planet?", [4]string{"Venus", "Jupiter", "Mars", "Saturn"}, 2},
48 {"What is the time complexity of binary search?", [4]string{"O(n)", "O(log n)", "O(n^2)", "O(1)"}, 1},
49 {"Which ocean is the largest by surface area?", [4]string{"Atlantic", "Indian", "Arctic", "Pacific"}, 3},
50 {"What does 'CPU' stand for?", [4]string{"Central Processing Unit", "Computer Personal Unit", "Central Program Utility", "Core Processing Unicode"}, 0},
51 {"How many bits are in a byte?", [4]string{"4", "8", "16", "32"}, 1},
52 }
53
54 players = avl.NewTree() // addr(string) -> *playerState
55)
56
57// startIndex picks a deterministic starting question for a brand-new player,
58// spreading players across the bank using their address and the height of
59// their first answer as entropy.
60func startIndex(addr string, height int64) int {
61 if height < 0 {
62 height = -height
63 }
64 var sum int64
65 for i := 0; i < len(addr); i++ {
66 sum += int64(addr[i])
67 }
68 n := int64(len(questions))
69 return int((sum + height) % n)
70}
71
72// getPlayer returns addr's state, lazily creating it on first contact.
73func getPlayer(addr string) *playerState {
74 if v, ok := players.Get(addr).(*playerState); ok {
75 return v
76 }
77 p := &playerState{qIdx: startIndex(addr, runtime.ChainHeight())}
78 players.Set(addr, p)
79 return p
80}
81
82// nextIndex advances a solved player to a fresh-feeling next question: the
83// step grows with the streak so a long run doesn't loop through the bank in
84// a visibly fixed order.
85func nextIndex(cur, streak, n int) int {
86 return (cur + 1 + streak) % n
87}
88
89// Answer submits a choice (0..3) for the caller's current question.
90// Crossing function: caller invokes as Answer(cross(cur), choiceIdx).
91func Answer(cur realm, choiceIdx int) string {
92 if !cur.IsCurrent() {
93 panic("spoofed realm")
94 }
95 if choiceIdx < 0 || choiceIdx >= len(questions[0].choices) {
96 panic("choice must be in 0..3")
97 }
98
99 addr := cur.Previous().Address().String()
100 p := getPlayer(addr)
101 q := questions[p.qIdx]
102
103 if choiceIdx != q.correctIdx {
104 brokeStreak := p.streak
105 p.streak = 0
106 p.wrong++
107 if brokeStreak > 0 {
108 return "Wrong — streak of " + strconv.Itoa(brokeStreak) + " broken. Same question stays live, try again."
109 }
110 return "Wrong — try again."
111 }
112
113 p.streak++
114 p.correct++
115 if p.streak > p.best {
116 p.best = p.streak
117 }
118 p.qIdx = nextIndex(p.qIdx, p.streak, len(questions))
119
120 chain.Emit("QuizAnswered",
121 "player", addr,
122 "streak", strconv.Itoa(p.streak),
123 "best", strconv.Itoa(p.best),
124 )
125
126 return "Correct! Streak is now " + strconv.Itoa(p.streak) + ". Next question is live."
127}
128
129// leaderboardEntry is a snapshot row used only for rendering, sorted by best
130// streak descending.
131type leaderboardEntry struct {
132 addr string
133 best int
134}
135
136func leaderboard() []leaderboardEntry {
137 var rows []leaderboardEntry
138 players.Iterate("", "", func(addr string, v any) bool {
139 rows = append(rows, leaderboardEntry{addr: addr, best: v.(*playerState).best})
140 return false
141 })
142 for i := 1; i < len(rows); i++ {
143 j := i
144 for j > 0 && rows[j-1].best < rows[j].best {
145 rows[j-1], rows[j] = rows[j], rows[j-1]
146 j--
147 }
148 }
149 return rows
150}
151
152func renderQuestion(b *strings.Builder, p *playerState) {
153 q := questions[p.qIdx]
154 letters := [4]string{"A", "B", "C", "D"}
155 b.WriteString("**" + q.text + "**\n\n")
156 for i, c := range q.choices {
157 b.WriteString("- **" + letters[i] + "** (" + strconv.Itoa(i) + "): " + c + "\n")
158 }
159 b.WriteString("\n- Current streak: " + strconv.Itoa(p.streak) + "\n")
160 b.WriteString("- Best streak: " + strconv.Itoa(p.best) + "\n")
161 b.WriteString("- Correct / wrong lifetime: " + strconv.Itoa(p.correct) + " / " + strconv.Itoa(p.wrong) + "\n\n")
162}
163
164// Render produces the gnoweb Markdown view. The root path shows the rules
165// and the leaderboard; a path of a bech32 address shows that player's
166// current question and stats.
167func Render(path string) string {
168 var b strings.Builder
169
170 b.WriteString("# Quiz Streak\n\n")
171 b.WriteString("Everyone plays their own trivia run at their own pace. Call ")
172 b.WriteString("`Answer(choiceIdx)` with 0-3 — a correct answer extends your streak ")
173 b.WriteString("and moves you to your next question; a wrong answer breaks your ")
174 b.WriteString("streak but leaves the same question live so you can retry.\n\n")
175
176 if path != "" {
177 if v, ok := players.Get(path).(*playerState); ok {
178 b.WriteString("## Your question (`" + path + "`)\n\n")
179 renderQuestion(&b, v)
180 } else {
181 b.WriteString("## `" + path + "`\n\n_No answers submitted yet — call `Answer` to get your first question._\n\n")
182 }
183 }
184
185 b.WriteString("## Best-streak leaderboard\n\n")
186 rows := leaderboard()
187 if len(rows) == 0 {
188 b.WriteString("_No one has played yet — be the first!_\n\n")
189 } else {
190 b.WriteString("| Player | Best streak |\n|---|---|\n")
191 for _, r := range rows {
192 b.WriteString("| `" + r.addr + "` | " + strconv.Itoa(r.best) + " |\n")
193 }
194 b.WriteString("\n")
195 }
196
197 b.WriteString("## How to play\n\n")
198 b.WriteString("```\n")
199 b.WriteString("gnokey maketx call -pkgpath gno.land/r/g12cs4cehujpffpjpywmkqj43m6u5ya53nj69sjz/quizstreak \\\n")
200 b.WriteString(" -func Answer -args <0|1|2|3> ...\n")
201 b.WriteString("```\n\n")
202 b.WriteString("View your own dashboard at `.../quizstreak:<your-address>`.\n")
203
204 return b.String()
205}