hangman.gno
7.35 Kb Β· 297 lines
1// Package hangman is a classic hangman game for gno.land.
2//
3// The secret word rotates every ~"day" (a fixed span of blocks) and is chosen
4// deterministically from a built-in word list using runtime.ChainHeight(). All
5// players share a single game per day: guesses and the wrong-count are common
6// state. Guess a letter to reveal every matching position, or lose a life. Six
7// wrong guesses ends the game.
8package hangman
9
10import (
11 "chain"
12 "chain/runtime"
13 "strings"
14
15 "gno.land/p/nt/avl/v0"
16)
17
18// blocksPerDay is the number of blocks that constitutes one "day" / one round.
19// The secret word is fixed for the whole span, then rotates.
20const blocksPerDay = 7200
21
22// maxWrong is the number of wrong guesses allowed before the game is lost.
23const maxWrong = 6
24
25// words is the built-in dictionary. Kept lowercase, letters only.
26var words = []string{
27 "gnoland",
28 "blockchain",
29 "realm",
30 "crossing",
31 "deterministic",
32 "contract",
33 "validator",
34 "consensus",
35 "package",
36 "namespace",
37 "immutable",
38 "transaction",
39 "signature",
40 "gopher",
41 "hangman",
42}
43
44// round holds the shared state for a single day's game.
45type round struct {
46 word string // the secret word for this round (lowercase)
47 guessed *avl.Tree // letter (string) -> struct{}; ordered set of guesses
48 wrong int // number of wrong guesses so far
49}
50
51// rounds maps a day index (decimal string) to its round. Persisted across calls.
52var rounds = avl.NewTree()
53
54// dayIndex returns the current day index derived from block height.
55func dayIndex() int64 {
56 return runtime.ChainHeight() / blocksPerDay
57}
58
59// wordForDay picks a word deterministically from the list for the given day.
60func wordForDay(day int64) string {
61 n := int64(len(words))
62 idx := day % n
63 if idx < 0 {
64 idx += n
65 }
66 return words[idx]
67}
68
69// dayKey formats a day index as a stable decimal string key.
70func dayKey(day int64) string {
71 neg := day < 0
72 if neg {
73 day = -day
74 }
75 if day == 0 {
76 return "0"
77 }
78 var b []byte
79 for day > 0 {
80 b = append([]byte{byte('0' + day%10)}, b...)
81 day /= 10
82 }
83 if neg {
84 b = append([]byte{'-'}, b...)
85 }
86 return string(b)
87}
88
89// current returns (creating if needed) the round for the current day.
90func current() *round {
91 day := dayIndex()
92 key := dayKey(day)
93 if v, ok := rounds.Get(key); ok {
94 return v.(*round)
95 }
96 r := &round{
97 word: wordForDay(day),
98 guessed: avl.NewTree(),
99 wrong: 0,
100 }
101 rounds.Set(key, r)
102 return r
103}
104
105// normalizeLetter lowercases and validates a single a-z letter, returning it
106// and true on success.
107func normalizeLetter(letter string) (string, bool) {
108 if len(letter) != 1 {
109 return "", false
110 }
111 c := letter[0]
112 if c >= 'A' && c <= 'Z' {
113 c = c - 'A' + 'a'
114 }
115 if c < 'a' || c > 'z' {
116 return "", false
117 }
118 return string(c), true
119}
120
121// isWon reports whether every letter of the word has been guessed.
122func (r *round) isWon() bool {
123 for i := 0; i < len(r.word); i++ {
124 if !r.guessed.Has(string(r.word[i])) {
125 return false
126 }
127 }
128 return true
129}
130
131// isLost reports whether the wrong-count has reached the limit.
132func (r *round) isLost() bool {
133 return r.wrong >= maxWrong
134}
135
136// over reports whether the round is finished either way.
137func (r *round) over() bool {
138 return r.isWon() || r.isLost()
139}
140
141// Guess submits a single letter for the current day's shared game. It reveals
142// matching letters or costs a life. Aborts on bad input or when the game is
143// already over.
144func Guess(cur realm, letter string) {
145 c, ok := normalizeLetter(letter)
146 if !ok {
147 panic("guess must be a single letter a-z")
148 }
149
150 r := current()
151 if r.over() {
152 panic("today's game is already over")
153 }
154 if r.guessed.Has(c) {
155 panic("letter already guessed: " + c)
156 }
157
158 r.guessed.Set(c, struct{}{})
159
160 hit := strings.Contains(r.word, c)
161 if !hit {
162 r.wrong++
163 }
164
165 caller := unsafeCaller(cur)
166 chain.Emit(
167 "Guess",
168 "letter", c,
169 "hit", boolStr(hit),
170 "wrong", itoa(int64(r.wrong)),
171 "by", caller,
172 )
173
174 if r.isWon() {
175 chain.Emit("Win", "day", dayKey(dayIndex()), "word", r.word)
176 } else if r.isLost() {
177 chain.Emit("Lose", "day", dayKey(dayIndex()), "word", r.word)
178 }
179}
180
181// unsafeCaller returns the caller address as a string, preferring the crossing
182// convention when available.
183func unsafeCaller(cur realm) string {
184 if cur.IsCurrent() {
185 return cur.Previous().Address().String()
186 }
187 return ""
188}
189
190// boolStr renders a bool compactly.
191func boolStr(b bool) string {
192 if b {
193 return "true"
194 }
195 return "false"
196}
197
198// itoa renders an int64 as decimal (avl-free tiny helper reused by Render).
199func itoa(n int64) string {
200 return dayKey(n)
201}
202
203// masked returns the word with un-guessed letters shown as underscores.
204func (r *round) masked() string {
205 var sb strings.Builder
206 for i := 0; i < len(r.word); i++ {
207 ch := string(r.word[i])
208 if r.guessed.Has(ch) {
209 sb.WriteString(ch)
210 } else {
211 sb.WriteString("_")
212 }
213 if i != len(r.word)-1 {
214 sb.WriteString(" ")
215 }
216 }
217 return sb.String()
218}
219
220// guessedList returns the guessed letters in sorted order, space-joined.
221func (r *round) guessedList() string {
222 var out []string
223 r.guessed.Iterate("", "", func(key string, _ interface{}) bool {
224 out = append(out, key)
225 return false
226 })
227 return strings.Join(out, " ")
228}
229
230// gallows renders the ASCII stage for the given wrong-count (0..6).
231func gallows(wrong int) string {
232 stages := []string{
233 // 0
234 " +---+\n | |\n |\n |\n |\n |\n=========",
235 // 1
236 " +---+\n | |\n O |\n |\n |\n |\n=========",
237 // 2
238 " +---+\n | |\n O |\n | |\n |\n |\n=========",
239 // 3
240 " +---+\n | |\n O |\n /| |\n |\n |\n=========",
241 // 4
242 " +---+\n | |\n O |\n /|\\ |\n |\n |\n=========",
243 // 5
244 " +---+\n | |\n O |\n /|\\ |\n / |\n |\n=========",
245 // 6
246 " +---+\n | |\n O |\n /|\\ |\n / \\ |\n |\n=========",
247 }
248 if wrong < 0 {
249 wrong = 0
250 }
251 if wrong >= len(stages) {
252 wrong = len(stages) - 1
253 }
254 return stages[wrong]
255}
256
257// Render shows the current day's game: gallows, masked word, guesses, status.
258func Render(path string) string {
259 r := current()
260
261 var sb strings.Builder
262 sb.WriteString("# πͺ’ Hangman\n\n")
263 sb.WriteString("A new secret word every day. Everyone shares the same game β guess a letter and see it revealed for all.\n\n")
264 sb.WriteString("Day: `" + dayKey(dayIndex()) + "` Β· Block: `" + itoa(runtime.ChainHeight()) + "`\n\n")
265
266 sb.WriteString("```\n")
267 sb.WriteString(gallows(r.wrong))
268 sb.WriteString("\n```\n\n")
269
270 sb.WriteString("## Word\n\n")
271 sb.WriteString("`" + r.masked() + "`\n\n")
272
273 sb.WriteString("Wrong: **" + itoa(int64(r.wrong)) + " / " + itoa(int64(maxWrong)) + "** Β· Lives left: **" + itoa(int64(maxWrong-r.wrong)) + "**\n\n")
274
275 guesses := r.guessedList()
276 if guesses == "" {
277 guesses = "_(none yet)_"
278 }
279 sb.WriteString("Guessed letters: " + guesses + "\n\n")
280
281 sb.WriteString("## Status\n\n")
282 switch {
283 case r.isWon():
284 sb.WriteString("π **Solved!** The word was **" + r.word + "**.\n\n")
285 case r.isLost():
286 sb.WriteString("π **Game over.** The word was **" + r.word + "**. Try again next day.\n\n")
287 default:
288 sb.WriteString("Game in progress. Call `Guess(\"a\")` with a letter.\n\n")
289 }
290
291 sb.WriteString("---\n\n")
292 sb.WriteString("### How to play\n\n")
293 sb.WriteString("- `Guess(cur, \"e\")` β guess a single letter `a`β`z`.\n")
294 sb.WriteString("- A hit reveals every matching position; a miss costs one of 6 lives.\n")
295 sb.WriteString("- The word rotates every ~" + itoa(int64(blocksPerDay)) + " blocks.\n")
296 return sb.String()
297}