Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

numguess.gno

6.80 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 := attempts.Get(addr); v != nil {
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 := attempts.Get(addr); v != nil {
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}