Search Apps Documentation Source Content File Folder Download Copy Actions Download

numguess.gno

6.79 Kb · 249 lines
  1// Package numguess is a Number Guessing Game realm.
  2//
  3// A hidden target in 1..100 is derived deterministically from the block height
  4// at which the round started. Players call Guess(n) to receive "higher",
  5// "lower" or "correct". When a round is solved the winner is recorded on the
  6// leaderboard (ranked by fewest guesses) and anyone can start a fresh round.
  7package numguess
  8
  9import (
 10	"strconv"
 11	"strings"
 12
 13	"chain"
 14	"chain/runtime"
 15
 16	"gno.land/p/nt/avl/v0"
 17)
 18
 19// round holds the mutable state of the active game round.
 20type round struct {
 21	number      int    // 1-based round counter
 22	startHeight int64  // block height the round began at (entropy source)
 23	target      int    // hidden value in 1..100
 24	solved      bool   // set once someone guesses correctly
 25	lastHint    string // "higher" / "lower" / "correct" / "" for none yet
 26	winner      string // address of the solver, once solved
 27	winnerTries int    // attempts the winner needed
 28}
 29
 30// winRecord is one leaderboard entry.
 31type winRecord struct {
 32	addr   string
 33	round  int
 34	tries  int
 35	target int
 36}
 37
 38var (
 39	current *round
 40	// attempts counts guesses per address for the CURRENT round only.
 41	attempts = avl.NewTree() // addr(string) -> *int
 42	// leaderboard is append-only across all rounds; sorted on render.
 43	leaderboard []winRecord
 44)
 45
 46func init() {
 47	current = newRound(1, runtime.ChainHeight())
 48}
 49
 50// newRound builds a fresh round whose target derives deterministically from
 51// the start height. Gno has no runtime RNG, so height is the only entropy.
 52func newRound(number int, height int64) *round {
 53	return &round{
 54		number:      number,
 55		startHeight: height,
 56		target:      targetFromHeight(height),
 57		solved:      false,
 58		lastHint:    "",
 59	}
 60}
 61
 62// targetFromHeight maps a block height to a hidden number in 1..100.
 63// Deterministic and cheap: a small integer mix so consecutive rounds don't
 64// land on obviously adjacent targets.
 65func targetFromHeight(h int64) int {
 66	if h < 0 {
 67		h = -h
 68	}
 69	mixed := (h*2654435761 + 12345)
 70	if mixed < 0 {
 71		mixed = -mixed
 72	}
 73	return int(mixed%100) + 1
 74}
 75
 76// Guess submits a guess for the current round. It records the caller's attempt
 77// count and returns "higher", "lower" or "correct". Crossing function: caller
 78// invokes as Guess(cross(cur), n).
 79func Guess(cur realm, n int) string {
 80	if !cur.IsCurrent() {
 81		panic("spoofed realm")
 82	}
 83	if n < 1 || n > 100 {
 84		panic("guess must be in 1..100")
 85	}
 86	if current.solved {
 87		panic("round already solved; call NewRound to start a fresh one")
 88	}
 89
 90	caller := cur.Previous().Address().String()
 91	incAttempt(caller)
 92
 93	var hint string
 94	switch {
 95	case n < current.target:
 96		hint = "higher"
 97	case n > current.target:
 98		hint = "lower"
 99	default:
100		hint = "correct"
101		current.solved = true
102		current.winner = caller
103		current.winnerTries = attemptsOf(caller)
104		leaderboard = append(leaderboard, winRecord{
105			addr:   caller,
106			round:  current.number,
107			tries:  current.winnerTries,
108			target: current.target,
109		})
110		chain.Emit("RoundSolved",
111			"round", strconv.Itoa(current.number),
112			"winner", caller,
113			"tries", strconv.Itoa(current.winnerTries),
114		)
115	}
116
117	current.lastHint = hint
118	return hint
119}
120
121// NewRound resets the game with a fresh target when the current round is
122// solved. Panics if the current round is still open. Crossing function.
123func NewRound(cur realm) string {
124	if !cur.IsCurrent() {
125		panic("spoofed realm")
126	}
127	if !current.solved {
128		panic("current round is not solved yet")
129	}
130
131	next := current.number + 1
132	current = newRound(next, runtime.ChainHeight())
133	attempts = avl.NewTree() // per-round attempt counters reset
134	chain.Emit("NewRound", "round", strconv.Itoa(next))
135	return "round " + strconv.Itoa(next) + " started"
136}
137
138// incAttempt bumps the caller's attempt counter for the current round.
139func incAttempt(addr string) {
140	if v, ok := attempts.Get(addr); ok {
141		p := v.(*int)
142		*p++
143		return
144	}
145	one := 1
146	attempts.Set(addr, &one)
147}
148
149// attemptsOf returns how many guesses addr has made this round.
150func attemptsOf(addr string) int {
151	if v, ok := attempts.Get(addr); ok {
152		return *(v.(*int))
153	}
154	return 0
155}
156
157// totalAttempts sums all attempts made in the current round.
158func totalAttempts() int {
159	total := 0
160	attempts.Iterate("", "", func(_ string, v any) bool {
161		total += *(v.(*int))
162		return false
163	})
164	return total
165}
166
167// Render produces the gnoweb Markdown view of the current game state.
168func Render(path string) string {
169	var b strings.Builder
170
171	b.WriteString("# Number Guessing Game\n\n")
172	b.WriteString("Guess the hidden number between **1** and **100**. ")
173	b.WriteString("Each round's target is derived deterministically from the block height at which it started.\n\n")
174
175	b.WriteString("## Current round\n\n")
176	b.WriteString("- Round: **" + strconv.Itoa(current.number) + "**\n")
177	b.WriteString("- Started at block height: " + strconv.FormatInt(current.startHeight, 10) + "\n")
178	b.WriteString("- Attempts so far (this round): " + strconv.Itoa(totalAttempts()) + "\n")
179
180	hint := current.lastHint
181	if hint == "" {
182		hint = "_no guesses yet_"
183	} else {
184		hint = "`" + hint + "`"
185	}
186	b.WriteString("- Last hint: " + hint + "\n")
187
188	if current.solved {
189		b.WriteString("- Status: **solved** by `" + current.winner + "` in " +
190			strconv.Itoa(current.winnerTries) + " guess(es). ")
191		b.WriteString("Call `NewRound()` to play again.\n")
192	} else {
193		b.WriteString("- Status: **open** — call `Guess(n)` to play.\n")
194	}
195	b.WriteString("\n")
196
197	b.WriteString("## Leaderboard\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..100 -> \"higher\" | \"lower\" | \"correct\"\n")
203	b.WriteString("NewRound()   // start a fresh round once the current one is solved\n")
204	b.WriteString("```\n")
205
206	return b.String()
207}
208
209// renderLeaderboard formats winners ranked by fewest guesses (ties: earlier
210// round first). Sorted on a copy so state is untouched.
211func renderLeaderboard() string {
212	if len(leaderboard) == 0 {
213		return "_No winners yet — be the first!_\n"
214	}
215
216	ranked := make([]winRecord, len(leaderboard))
217	copy(ranked, leaderboard)
218	sortByFewestTries(ranked)
219
220	var b strings.Builder
221	b.WriteString("| Rank | Player | Round | Guesses | Target |\n")
222	b.WriteString("|---|---|---|---|---|\n")
223	for i, w := range ranked {
224		b.WriteString("| " + strconv.Itoa(i+1) + " | `" + w.addr + "` | " +
225			strconv.Itoa(w.round) + " | " + strconv.Itoa(w.tries) + " | " +
226			strconv.Itoa(w.target) + " |\n")
227	}
228	return b.String()
229}
230
231// sortByFewestTries does an in-place insertion sort: fewer tries first, then
232// earlier round first on ties. Small n; insertion sort keeps it deterministic
233// and dependency-free.
234func sortByFewestTries(rs []winRecord) {
235	for i := 1; i < len(rs); i++ {
236		j := i
237		for j > 0 && less(rs[j], rs[j-1]) {
238			rs[j], rs[j-1] = rs[j-1], rs[j]
239			j--
240		}
241	}
242}
243
244func less(a, b winRecord) bool {
245	if a.tries != b.tries {
246		return a.tries < b.tries
247	}
248	return a.round < b.round
249}