// Package wordle is a daily five-letter word puzzle realm for gno.land. // // The day's secret word is picked deterministically from the current block // height (a "day index"), so every player faces the same word for the same // span of blocks. Players submit guesses with Guess; each guess is scored // letter-by-letter with emoji feedback (🟩 correct spot, 🟨 wrong spot, // ⬜ absent). Render shows the caller's board, attempts left, and rules. package wordle import ( "chain/runtime" "chain/runtime/unsafe" "strings" ) // maxAttempts is the number of guesses a player gets per day. const maxAttempts = 6 // wordLen is the fixed length of every word / guess. const wordLen = 5 // blocksPerDay controls how many blocks share the same secret word. Testnet // blocks are fast, so this is a pragmatic "day" for a demo puzzle rather than // a wall-clock day. Deterministic: derived only from block height. const blocksPerDay = 17280 // words is the built-in dictionary of candidate secret words. All entries are // exactly wordLen lowercase letters. var words = []string{ "crane", "slate", "pride", "ghost", "lunar", "mango", "vivid", "quilt", "brave", "flame", "storm", "pearl", "trout", "zebra", "cabin", } // board holds one player's guesses for a given day. type board struct { day int64 guesses []string // raw lowercase guessed words, in order won bool } // players maps a caller address string to that player's current board. // A plain map is fine here: keys are addresses, iteration order is never // used for rendering (we only ever look up the caller's own board). var players = map[string]*board{} // dayIndex returns the deterministic day index for a given block height. func dayIndex(height int64) int64 { if height < 0 { height = 0 } return height / blocksPerDay } // wordForDay returns the secret word for the given day index. Deterministic: // a pure function of the day index and the built-in dictionary. func wordForDay(day int64) string { n := int64(len(words)) idx := day % n if idx < 0 { idx += n } return words[idx] } // currentDay returns today's day index from the chain height. func currentDay() int64 { return dayIndex(runtime.ChainHeight()) } // score compares a guess against the secret and returns wordLen emoji tiles: // 🟩 right letter right spot, 🟨 right letter wrong spot, ⬜ letter absent. // Standard Wordle two-pass scoring so duplicate letters are handled correctly. func score(guess, secret string) string { g := []rune(guess) s := []rune(secret) tiles := make([]string, wordLen) // counts of each secret letter still available to match as 🟨. counts := map[rune]int{} // First pass: greens. for i := 0; i < wordLen; i++ { if g[i] == s[i] { tiles[i] = "🟩" } else { counts[s[i]]++ } } // Second pass: yellows / greys. for i := 0; i < wordLen; i++ { if tiles[i] == "🟩" { continue } if counts[g[i]] > 0 { tiles[i] = "🟨" counts[g[i]]-- } else { tiles[i] = "⬜" } } return strings.Join(tiles, "") } // isFiveLetters reports whether w is exactly wordLen ASCII lowercase letters. func isFiveLetters(w string) bool { if len(w) != wordLen { return false } for i := 0; i < len(w); i++ { c := w[i] if c < 'a' || c > 'z' { return false } } return true } // Guess records a five-letter guess for the caller against today's word. // // It is a crossing (state-mutating) function: callers invoke it as // Guess(cross(cur), "crane"). Identity is taken from the live crossing frame // after the IsCurrent authentication check. func Guess(cur realm, word string) { if !cur.IsCurrent() { panic("spoofed realm") } caller := cur.Previous().Address().String() word = strings.ToLower(strings.TrimSpace(word)) if !isFiveLetters(word) { panic("guess must be exactly 5 lowercase letters a-z") } day := currentDay() b := players[caller] if b == nil || b.day != day { // New player, or a new day rolled over: reset the board. b = &board{day: day} players[caller] = b } if b.won { panic("you already solved today's word") } if len(b.guesses) >= maxAttempts { panic("no attempts left today") } b.guesses = append(b.guesses, word) if word == wordForDay(day) { b.won = true } } // Render returns the caller's board as Markdown. It is NOT a crossing function // (no cur realm parameter). It reads the viewer via the stack-walking // unsafe.PreviousRealm(); an unknown viewer simply sees an empty board. func Render(path string) string { viewer := unsafe.PreviousRealm().Address().String() day := currentDay() var sb strings.Builder sb.WriteString("# 🟩 Daily Word Puzzle\n\n") sb.WriteString("A Wordle-like game. Everyone gets the same secret 5-letter word each ") sb.WriteString("\"day\" (a span of blocks). You have ") sb.WriteString(itoa(maxAttempts)) sb.WriteString(" attempts.\n\n") sb.WriteString("**Day #") sb.WriteString(i64toa(day)) sb.WriteString("**\n\n") b := players[viewer] if b == nil || b.day != day { sb.WriteString("_No guesses yet today._\n\n") writeRules(&sb) return sb.String() } secret := wordForDay(day) sb.WriteString("## Your board\n\n") for _, g := range b.guesses { sb.WriteString(score(g, secret)) sb.WriteString(" `") sb.WriteString(strings.ToUpper(g)) sb.WriteString("`\n\n") } left := maxAttempts - len(b.guesses) switch { case b.won: sb.WriteString("🎉 **You solved it!** Come back next day for a new word.\n\n") case left <= 0: sb.WriteString("💀 **Out of attempts.** The word was `") sb.WriteString(strings.ToUpper(secret)) sb.WriteString("`. Try again next day.\n\n") default: sb.WriteString("**Attempts left:** ") sb.WriteString(itoa(left)) sb.WriteString("\n\n") } writeRules(&sb) return sb.String() } // writeRules appends the legend / how-to-play block. func writeRules(sb *strings.Builder) { sb.WriteString("## Rules\n\n") sb.WriteString("- Guess the 5-letter word in ") sb.WriteString(itoa(maxAttempts)) sb.WriteString(" tries: `Guess(\"crane\")`.\n") sb.WriteString("- 🟩 correct letter, correct spot.\n") sb.WriteString("- 🟨 correct letter, wrong spot.\n") sb.WriteString("- ⬜ letter not in the word.\n") sb.WriteString("- The word changes every day (deterministic from block height).\n") } // itoa formats a small non-negative int (attempt counts) without importing // strconv, keeping the dependency surface minimal. func itoa(n int) string { return i64toa(int64(n)) } // i64toa formats an int64 as decimal. func i64toa(n int64) string { if n == 0 { return "0" } neg := n < 0 if neg { n = -n } var digits []byte for n > 0 { digits = append(digits, byte('0'+n%10)) n /= 10 } // reverse for i, j := 0, len(digits)-1; i < j; i, j = i+1, j-1 { digits[i], digits[j] = digits[j], digits[i] } if neg { return "-" + string(digits) } return string(digits) }