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}