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

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}