Search Apps Documentation Source Content File Folder Download Copy Actions Download

wordle.gno

6.73 Kb Β· 260 lines
  1// Package wordle is a daily five-letter word puzzle realm for gno.land.
  2//
  3// The day's secret word is picked deterministically from the current block
  4// height (a "day index"), so every player faces the same word for the same
  5// span of blocks. Players submit guesses with Guess; each guess is scored
  6// letter-by-letter with emoji feedback (🟩 correct spot, 🟨 wrong spot,
  7// ⬜ absent). Render shows the caller's board, attempts left, and rules.
  8package wordle
  9
 10import (
 11	"chain/runtime"
 12	"chain/runtime/unsafe"
 13	"strings"
 14)
 15
 16// maxAttempts is the number of guesses a player gets per day.
 17const maxAttempts = 6
 18
 19// wordLen is the fixed length of every word / guess.
 20const wordLen = 5
 21
 22// blocksPerDay controls how many blocks share the same secret word. Testnet
 23// blocks are fast, so this is a pragmatic "day" for a demo puzzle rather than
 24// a wall-clock day. Deterministic: derived only from block height.
 25const blocksPerDay = 17280
 26
 27// words is the built-in dictionary of candidate secret words. All entries are
 28// exactly wordLen lowercase letters.
 29var words = []string{
 30	"crane",
 31	"slate",
 32	"pride",
 33	"ghost",
 34	"lunar",
 35	"mango",
 36	"vivid",
 37	"quilt",
 38	"brave",
 39	"flame",
 40	"storm",
 41	"pearl",
 42	"trout",
 43	"zebra",
 44	"cabin",
 45}
 46
 47// board holds one player's guesses for a given day.
 48type board struct {
 49	day     int64
 50	guesses []string // raw lowercase guessed words, in order
 51	won     bool
 52}
 53
 54// players maps a caller address string to that player's current board.
 55// A plain map is fine here: keys are addresses, iteration order is never
 56// used for rendering (we only ever look up the caller's own board).
 57var players = map[string]*board{}
 58
 59// dayIndex returns the deterministic day index for a given block height.
 60func dayIndex(height int64) int64 {
 61	if height < 0 {
 62		height = 0
 63	}
 64	return height / blocksPerDay
 65}
 66
 67// wordForDay returns the secret word for the given day index. Deterministic:
 68// a pure function of the day index and the built-in dictionary.
 69func wordForDay(day int64) string {
 70	n := int64(len(words))
 71	idx := day % n
 72	if idx < 0 {
 73		idx += n
 74	}
 75	return words[idx]
 76}
 77
 78// currentDay returns today's day index from the chain height.
 79func currentDay() int64 {
 80	return dayIndex(runtime.ChainHeight())
 81}
 82
 83// score compares a guess against the secret and returns wordLen emoji tiles:
 84// 🟩 right letter right spot, 🟨 right letter wrong spot, ⬜ letter absent.
 85// Standard Wordle two-pass scoring so duplicate letters are handled correctly.
 86func score(guess, secret string) string {
 87	g := []rune(guess)
 88	s := []rune(secret)
 89
 90	tiles := make([]string, wordLen)
 91	// counts of each secret letter still available to match as 🟨.
 92	counts := map[rune]int{}
 93
 94	// First pass: greens.
 95	for i := 0; i < wordLen; i++ {
 96		if g[i] == s[i] {
 97			tiles[i] = "🟩"
 98		} else {
 99			counts[s[i]]++
100		}
101	}
102	// Second pass: yellows / greys.
103	for i := 0; i < wordLen; i++ {
104		if tiles[i] == "🟩" {
105			continue
106		}
107		if counts[g[i]] > 0 {
108			tiles[i] = "🟨"
109			counts[g[i]]--
110		} else {
111			tiles[i] = "⬜"
112		}
113	}
114	return strings.Join(tiles, "")
115}
116
117// isFiveLetters reports whether w is exactly wordLen ASCII lowercase letters.
118func isFiveLetters(w string) bool {
119	if len(w) != wordLen {
120		return false
121	}
122	for i := 0; i < len(w); i++ {
123		c := w[i]
124		if c < 'a' || c > 'z' {
125			return false
126		}
127	}
128	return true
129}
130
131// Guess records a five-letter guess for the caller against today's word.
132//
133// It is a crossing (state-mutating) function: callers invoke it as
134// Guess(cross(cur), "crane"). Identity is taken from the live crossing frame
135// after the IsCurrent authentication check.
136func Guess(cur realm, word string) {
137	if !cur.IsCurrent() {
138		panic("spoofed realm")
139	}
140	caller := cur.Previous().Address().String()
141
142	word = strings.ToLower(strings.TrimSpace(word))
143	if !isFiveLetters(word) {
144		panic("guess must be exactly 5 lowercase letters a-z")
145	}
146
147	day := currentDay()
148	b := players[caller]
149	if b == nil || b.day != day {
150		// New player, or a new day rolled over: reset the board.
151		b = &board{day: day}
152		players[caller] = b
153	}
154
155	if b.won {
156		panic("you already solved today's word")
157	}
158	if len(b.guesses) >= maxAttempts {
159		panic("no attempts left today")
160	}
161
162	b.guesses = append(b.guesses, word)
163	if word == wordForDay(day) {
164		b.won = true
165	}
166}
167
168// Render returns the caller's board as Markdown. It is NOT a crossing function
169// (no cur realm parameter). It reads the viewer via the stack-walking
170// unsafe.PreviousRealm(); an unknown viewer simply sees an empty board.
171func Render(path string) string {
172	viewer := unsafe.PreviousRealm().Address().String()
173	day := currentDay()
174
175	var sb strings.Builder
176	sb.WriteString("# 🟩 Daily Word Puzzle\n\n")
177	sb.WriteString("A Wordle-like game. Everyone gets the same secret 5-letter word each ")
178	sb.WriteString("\"day\" (a span of blocks). You have ")
179	sb.WriteString(itoa(maxAttempts))
180	sb.WriteString(" attempts.\n\n")
181
182	sb.WriteString("**Day #")
183	sb.WriteString(i64toa(day))
184	sb.WriteString("**\n\n")
185
186	b := players[viewer]
187	if b == nil || b.day != day {
188		sb.WriteString("_No guesses yet today._\n\n")
189		writeRules(&sb)
190		return sb.String()
191	}
192
193	secret := wordForDay(day)
194	sb.WriteString("## Your board\n\n")
195	for _, g := range b.guesses {
196		sb.WriteString(score(g, secret))
197		sb.WriteString("  `")
198		sb.WriteString(strings.ToUpper(g))
199		sb.WriteString("`\n\n")
200	}
201
202	left := maxAttempts - len(b.guesses)
203	switch {
204	case b.won:
205		sb.WriteString("πŸŽ‰ **You solved it!** Come back next day for a new word.\n\n")
206	case left <= 0:
207		sb.WriteString("πŸ’€ **Out of attempts.** The word was `")
208		sb.WriteString(strings.ToUpper(secret))
209		sb.WriteString("`. Try again next day.\n\n")
210	default:
211		sb.WriteString("**Attempts left:** ")
212		sb.WriteString(itoa(left))
213		sb.WriteString("\n\n")
214	}
215
216	writeRules(&sb)
217	return sb.String()
218}
219
220// writeRules appends the legend / how-to-play block.
221func writeRules(sb *strings.Builder) {
222	sb.WriteString("## Rules\n\n")
223	sb.WriteString("- Guess the 5-letter word in ")
224	sb.WriteString(itoa(maxAttempts))
225	sb.WriteString(" tries: `Guess(\"crane\")`.\n")
226	sb.WriteString("- 🟩 correct letter, correct spot.\n")
227	sb.WriteString("- 🟨 correct letter, wrong spot.\n")
228	sb.WriteString("- ⬜ letter not in the word.\n")
229	sb.WriteString("- The word changes every day (deterministic from block height).\n")
230}
231
232// itoa formats a small non-negative int (attempt counts) without importing
233// strconv, keeping the dependency surface minimal.
234func itoa(n int) string {
235	return i64toa(int64(n))
236}
237
238// i64toa formats an int64 as decimal.
239func i64toa(n int64) string {
240	if n == 0 {
241		return "0"
242	}
243	neg := n < 0
244	if neg {
245		n = -n
246	}
247	var digits []byte
248	for n > 0 {
249		digits = append(digits, byte('0'+n%10))
250		n /= 10
251	}
252	// reverse
253	for i, j := 0, len(digits)-1; i < j; i, j = i+1, j-1 {
254		digits[i], digits[j] = digits[j], digits[i]
255	}
256	if neg {
257		return "-" + string(digits)
258	}
259	return string(digits)
260}