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}