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

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}