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