wordle.gno

// 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)
}