closestguess.gno
7.62 Kb · 255 lines
1// Package closestguess is a blind, multiplayer number-guessing puzzle.
2//
3// Unlike a classic higher/lower guessing game, closestguess gives NO feedback
4// per guess. Each round hides a target in 1..1000 (derived deterministically
5// from the block height the round started at); every address may submit
6// exactly one blind Guess for the round. Whenever someone calls Reveal, the
7// round closes: the target is disclosed, the guess with the smallest absolute
8// distance wins (ties go to whoever guessed first), the winner is recorded on
9// the leaderboard, and a fresh round starts immediately.
10package closestguess
11
12import (
13 "strconv"
14 "strings"
15
16 "chain"
17 "chain/runtime"
18)
19
20const (
21 minTarget = 1
22 maxTarget = 1000
23)
24
25// roundT holds the mutable state of the open round. The target stays hidden
26// (never rendered, never returned) until Reveal closes the round.
27type roundT struct {
28 number int
29 startHeight int64
30 target int
31}
32
33// winRecord is one closed-round result, kept for the leaderboard.
34type winRecord struct {
35 round int
36 winner string
37 target int
38 guess int
39 distance int
40 players int
41}
42
43// guessEntry is one address's blind guess for the current round.
44type guessEntry struct {
45 addr string
46 n int
47}
48
49var (
50 current *roundT
51 // entries holds one guessEntry per address for the CURRENT round only, in
52 // submission order (so Reveal can break distance ties in favor of
53 // whoever guessed first). Reset on every Reveal. A plain slice is fine
54 // here: rounds are player-scoped and small, unlike long-lived global
55 // state where avl.Tree would matter.
56 entries []guessEntry
57 // leaderboard is append-only across all rounds; sorted on render.
58 leaderboard []winRecord
59)
60
61func init() {
62 current = newRound(1, runtime.ChainHeight())
63}
64
65// newRound builds a fresh round whose hidden target derives deterministically
66// from the start height and round number. Gno has no runtime RNG, so mixing
67// height with the round number is the only available entropy, and it also
68// keeps consecutive rounds at the same height from repeating a target.
69func newRound(number int, height int64) *roundT {
70 return &roundT{
71 number: number,
72 startHeight: height,
73 target: targetFromHeight(height, number),
74 }
75}
76
77func targetFromHeight(h int64, round int) int {
78 if h < 0 {
79 h = -h
80 }
81 span := int64(maxTarget - minTarget + 1)
82 mixed := h*2654435761 + int64(round)*40503 + 12345
83 if mixed < 0 {
84 mixed = -mixed
85 }
86 return int(mixed%span) + minTarget
87}
88
89// Guess records the caller's single blind guess for the current round. It
90// returns no hint about correctness — that is the whole point of the puzzle.
91// Crossing function: caller invokes as Guess(cross(cur), n).
92func Guess(cur realm, n int) string {
93 if n < minTarget || n > maxTarget {
94 panic("guess must be in " + strconv.Itoa(minTarget) + ".." + strconv.Itoa(maxTarget))
95 }
96
97 caller := cur.Previous().Address().String()
98 if hasGuessed(caller) {
99 panic("you already guessed this round; wait for Reveal()")
100 }
101
102 entries = append(entries, guessEntry{addr: caller, n: n})
103
104 chain.Emit("GuessSubmitted",
105 "round", strconv.Itoa(current.number),
106 "player", caller,
107 )
108
109 return "guess recorded for round " + strconv.Itoa(current.number) + " (" +
110 strconv.Itoa(len(entries)) + " player(s) so far) — call Reveal() to see who's closest"
111}
112
113func hasGuessed(addr string) bool {
114 for _, e := range entries {
115 if e.addr == addr {
116 return true
117 }
118 }
119 return false
120}
121
122// Reveal closes the current round: discloses the target, crowns whoever
123// landed closest (ties favor the earliest guesser), records a leaderboard
124// entry, and opens a fresh round. Panics if nobody has guessed yet.
125// Crossing function.
126func Reveal(cur realm) string {
127 if len(entries) == 0 {
128 panic("no guesses submitted yet this round")
129 }
130
131 target := current.target
132 winner := entries[0]
133 best := distance(winner.n, target)
134 for _, e := range entries[1:] {
135 if d := distance(e.n, target); d < best {
136 best = d
137 winner = e
138 }
139 }
140
141 rec := winRecord{
142 round: current.number,
143 winner: winner.addr,
144 target: target,
145 guess: winner.n,
146 distance: best,
147 players: len(entries),
148 }
149 leaderboard = append(leaderboard, rec)
150
151 chain.Emit("RoundRevealed",
152 "round", strconv.Itoa(rec.round),
153 "winner", winner.addr,
154 "target", strconv.Itoa(target),
155 "distance", strconv.Itoa(best),
156 )
157
158 closedRound := current.number
159 current = newRound(closedRound+1, runtime.ChainHeight())
160 entries = nil
161
162 return "round " + strconv.Itoa(closedRound) + " revealed: target was " + strconv.Itoa(target) +
163 " — " + shortAddr(winner.addr) + " wins, guessed " + strconv.Itoa(winner.n) +
164 " (distance " + strconv.Itoa(best) + ") among " + strconv.Itoa(rec.players) +
165 " player(s). Round " + strconv.Itoa(current.number) + " is now open."
166}
167
168func distance(guess, target int) int {
169 d := guess - target
170 if d < 0 {
171 d = -d
172 }
173 return d
174}
175
176// Render produces the gnoweb Markdown view. The current round's target and
177// individual guess values are never shown — only the player count — so
178// rendering the page can't leak the puzzle.
179func Render(path string) string {
180 var b strings.Builder
181
182 b.WriteString("# Closest Guess\n\n")
183 b.WriteString("A blind, multiplayer number-guessing puzzle. Each round hides a target in ")
184 b.WriteString("**" + strconv.Itoa(minTarget) + ".." + strconv.Itoa(maxTarget) + "**. ")
185 b.WriteString("Call `Guess(n)` once per round — you get no feedback. ")
186 b.WriteString("Anyone can call `Reveal()` to close the round: the target is disclosed and ")
187 b.WriteString("whoever landed closest wins.\n\n")
188
189 b.WriteString("## Current round\n\n")
190 b.WriteString("- Round: **" + strconv.Itoa(current.number) + "**\n")
191 b.WriteString("- Started at block height: " + strconv.FormatInt(current.startHeight, 10) + "\n")
192 b.WriteString("- Players guessed so far: " + strconv.Itoa(len(entries)) + "\n")
193 b.WriteString("- Status: **open** — target hidden until `Reveal()` is called.\n\n")
194
195 b.WriteString("## Leaderboard (closest-ever wins)\n\n")
196 b.WriteString(renderLeaderboard())
197
198 b.WriteString("\n## How to play\n\n")
199 b.WriteString("```\n")
200 b.WriteString("Guess(n) // n in 1..1000, one shot per address per round, no feedback\n")
201 b.WriteString("Reveal() // closes the round: reveals target, crowns the closest guess\n")
202 b.WriteString("```\n")
203
204 return b.String()
205}
206
207// renderLeaderboard formats closed rounds ranked by smallest distance (ties:
208// earlier round first). Sorted on a copy so state is untouched.
209func renderLeaderboard() string {
210 if len(leaderboard) == 0 {
211 return "_No round has been revealed yet — be the first to call `Reveal()`!_\n"
212 }
213
214 ranked := make([]winRecord, len(leaderboard))
215 copy(ranked, leaderboard)
216 sortByClosest(ranked)
217
218 var b strings.Builder
219 b.WriteString("| Rank | Player | Round | Target | Guess | Distance | Players |\n")
220 b.WriteString("|---|---|---|---|---|---|---|\n")
221 for i, w := range ranked {
222 b.WriteString("| " + strconv.Itoa(i+1) + " | `" + shortAddr(w.winner) + "` | " +
223 strconv.Itoa(w.round) + " | " + strconv.Itoa(w.target) + " | " +
224 strconv.Itoa(w.guess) + " | " + strconv.Itoa(w.distance) + " | " +
225 strconv.Itoa(w.players) + " |\n")
226 }
227 return b.String()
228}
229
230// sortByClosest does an in-place insertion sort: smallest distance first,
231// then earlier round first on ties. Small n; insertion sort keeps it
232// deterministic and dependency-free.
233func sortByClosest(rs []winRecord) {
234 for i := 1; i < len(rs); i++ {
235 j := i
236 for j > 0 && closer(rs[j], rs[j-1]) {
237 rs[j], rs[j-1] = rs[j-1], rs[j]
238 j--
239 }
240 }
241}
242
243func closer(a, b winRecord) bool {
244 if a.distance != b.distance {
245 return a.distance < b.distance
246 }
247 return a.round < b.round
248}
249
250func shortAddr(a string) string {
251 if len(a) <= 12 {
252 return a
253 }
254 return a[:6] + "…" + a[len(a)-4:]
255}