Search Apps Documentation Source Content File Folder Download Copy Actions Download

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}