Persist Wordle guesses, fix hardcoded rendering, announce expired puzzles

- Add wordle_guesses table to persist individual guesses to DB
- Reload guesses during rehydration so they survive bot restarts
- Clear stale guesses when puzzle is replaced (skip+new)
- Fix all hardcoded /6 and 🟩🟩🟩🟩🟩 in rendering to use puzzle.MaxGuesses
  and puzzle.WordLength
- Announce unsolved puzzles at midnight rollover instead of silently replacing
- Mark expired puzzles as failed and update stats on rollover

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-04-01 22:11:01 -07:00
parent b4d5080f48
commit a0573e67a5
3 changed files with 96 additions and 8 deletions

View File

@@ -822,6 +822,17 @@ CREATE TABLE IF NOT EXISTS wordle_puzzles (
PRIMARY KEY (puzzle_id, room_id) PRIMARY KEY (puzzle_id, room_id)
); );
CREATE TABLE IF NOT EXISTS wordle_guesses (
puzzle_id TEXT NOT NULL,
room_id TEXT NOT NULL,
guess_num INTEGER NOT NULL,
word TEXT NOT NULL,
player_id TEXT NOT NULL,
player_name TEXT NOT NULL,
guessed_at DATETIME NOT NULL,
PRIMARY KEY (puzzle_id, room_id, guess_num)
);
-- Space groups (rooms with overlapping membership) -- Space groups (rooms with overlapping membership)
CREATE TABLE IF NOT EXISTS space_groups ( CREATE TABLE IF NOT EXISTS space_groups (
room_id TEXT PRIMARY KEY, room_id TEXT PRIMARY KEY,

View File

@@ -180,6 +180,13 @@ func (p *WordlePlugin) handleGuess(ctx MessageContext, guess string) error {
} }
puzzle.Guesses = append(puzzle.Guesses, g) puzzle.Guesses = append(puzzle.Guesses, g)
// Persist guess to DB so it survives restarts.
db.Exec("wordle: persist guess",
`INSERT INTO wordle_guesses (puzzle_id, room_id, guess_num, word, player_id, player_name, guessed_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
puzzle.PuzzleID, string(puzzle.RoomID), len(puzzle.Guesses), guess, string(ctx.Sender), displayName, now,
)
// Check for win. // Check for win.
if guess == puzzle.Answer { if guess == puzzle.Answer {
puzzle.Solved = true puzzle.Solved = true
@@ -235,7 +242,7 @@ func (p *WordlePlugin) handleNew(ctx MessageContext, args string) error {
category := WordleCategoryEN category := WordleCategoryEN
parts := strings.Fields(args) parts := strings.Fields(args)
// Parse optional arguments: language (pt/fr) and/or length (5/6/7) // Parse optional arguments: language (pt/fr) and/or length (5-20)
for _, part := range parts[1:] { for _, part := range parts[1:] {
lower := strings.ToLower(part) lower := strings.ToLower(part)
switch lower { switch lower {
@@ -353,6 +360,11 @@ func (p *WordlePlugin) createAndPostPuzzle(roomID id.RoomID, wordLength int, cat
today, string(roomID), word, wordLength, string(category), now, today, string(roomID), word, wordLength, string(category), now,
word, wordLength, string(category), now, word, wordLength, string(category), now,
) )
// Clear any stale guesses from a previous puzzle on the same day (e.g. after skip+new).
db.Exec("wordle: clear old guesses",
`DELETE FROM wordle_guesses WHERE puzzle_id = ? AND room_id = ?`,
today, string(roomID),
)
p.mu.Lock() p.mu.Lock()
p.puzzles[roomID] = puzzle p.puzzles[roomID] = puzzle
@@ -432,6 +444,29 @@ func wordleCategoryHint(category WordleCategory) string {
return "" return ""
} }
// expireUnsolved marks an active puzzle as failed and posts an announcement.
// Must be called without holding p.mu.
func (p *WordlePlugin) expireUnsolved(roomID id.RoomID) {
p.mu.Lock()
existing := p.puzzles[roomID]
if existing == nil || existing.Solved || existing.Failed {
p.mu.Unlock()
return
}
existing.Failed = true
p.markPuzzleDone(existing)
p.updateStats(existing)
p.mu.Unlock()
definition := p.fetchDefinition(existing.Answer)
defLine := ""
if definition != "" {
defLine = fmt.Sprintf("\n📖 *%s*\n", definition)
}
p.SendMessage(roomID,
fmt.Sprintf("⏰ **Time's up!** Yesterday's puzzle expired.\nThe word was **%s**.%s", existing.Answer, defLine))
}
// PostDailyPuzzle is called by the scheduler to post today's puzzle. // PostDailyPuzzle is called by the scheduler to post today's puzzle.
func (p *WordlePlugin) PostDailyPuzzle(roomID id.RoomID) error { func (p *WordlePlugin) PostDailyPuzzle(roomID id.RoomID) error {
today := time.Now().UTC().Format("2006-01-02") today := time.Now().UTC().Format("2006-01-02")
@@ -445,6 +480,9 @@ func (p *WordlePlugin) PostDailyPuzzle(roomID id.RoomID) error {
} }
p.mu.Unlock() p.mu.Unlock()
// Announce expiry of yesterday's unsolved puzzle before creating the new one.
p.expireUnsolved(roomID)
return p.createAndPostPuzzle(roomID, p.defaultLength, WordleCategoryEN) return p.createAndPostPuzzle(roomID, p.defaultLength, WordleCategoryEN)
} }
@@ -739,12 +777,48 @@ func (p *WordlePlugin) rehydratePuzzles() {
LetterStates: make(map[rune]LetterResult), LetterStates: make(map[rune]LetterResult),
} }
// Reload persisted guesses.
guessRows, err := d.Query(
`SELECT word, player_id, player_name, guessed_at FROM wordle_guesses
WHERE puzzle_id = ? AND room_id = ? ORDER BY guess_num ASC`,
pr.pid, pr.roomStr,
)
if err == nil {
for guessRows.Next() {
var word, playerID, playerName string
var guessedAt time.Time
if err := guessRows.Scan(&word, &playerID, &playerName, &guessedAt); err != nil {
continue
}
results := scoreGuess(word, puzzle.Answer)
updateLetterStates(puzzle.LetterStates, word, results)
puzzle.Guesses = append(puzzle.Guesses, WordleGuess{
Word: word,
PlayerID: id.UserID(playerID),
PlayerName: playerName,
Results: results,
Timestamp: guessedAt,
})
}
guessRows.Close()
}
// Check if puzzle is actually done (guesses may have filled up).
if len(puzzle.Guesses) >= puzzle.MaxGuesses {
puzzle.Failed = true
continue
}
if len(puzzle.Guesses) > 0 && puzzle.Guesses[len(puzzle.Guesses)-1].Word == puzzle.Answer {
puzzle.Solved = true
continue
}
p.mu.Lock() p.mu.Lock()
p.puzzles[roomID] = puzzle p.puzzles[roomID] = puzzle
p.validCache[pr.pid] = make(map[string]bool) p.validCache[pr.pid] = make(map[string]bool)
p.mu.Unlock() p.mu.Unlock()
slog.Info("wordle: rehydrated puzzle", "room", roomID, "answer_len", pr.wordLength) slog.Info("wordle: rehydrated puzzle", "room", roomID, "answer_len", pr.wordLength, "guesses", len(puzzle.Guesses))
} }
} }
@@ -789,6 +863,9 @@ func (p *WordlePlugin) checkAndPostDaily() {
return return
} }
// Announce expiry of yesterday's unsolved puzzle before creating the new one.
p.expireUnsolved(gr)
slog.Info("wordle: posting daily puzzle", "room", gr) slog.Info("wordle: posting daily puzzle", "room", gr)
if err := p.createAndPostPuzzle(gr, p.defaultLength, WordleCategoryEN); err != nil { if err := p.createAndPostPuzzle(gr, p.defaultLength, WordleCategoryEN); err != nil {
slog.Error("wordle: daily puzzle failed", "err", err) slog.Error("wordle: daily puzzle failed", "err", err)

View File

@@ -21,9 +21,9 @@ func letterEmoji(r LetterResult) string {
func renderWordleGrid(puzzle *WordlePuzzle) string { func renderWordleGrid(puzzle *WordlePuzzle) string {
var sb strings.Builder var sb strings.Builder
status := fmt.Sprintf("%d/6", len(puzzle.Guesses)) status := fmt.Sprintf("%d/%d", len(puzzle.Guesses), puzzle.MaxGuesses)
if puzzle.Solved { if puzzle.Solved {
status += " 🟩🟩🟩🟩🟩" status += " " + strings.Repeat("🟩", puzzle.WordLength)
} }
sb.WriteString(fmt.Sprintf("🟩 **Wordle #%d** — %s\n\n", puzzle.PuzzleNumber, status)) sb.WriteString(fmt.Sprintf("🟩 **Wordle #%d** — %s\n\n", puzzle.PuzzleNumber, status))
@@ -74,8 +74,8 @@ func renderKeyboard(states map[rune]LetterResult) string {
// renderWordleStartAnnouncement renders the puzzle start message. // renderWordleStartAnnouncement renders the puzzle start message.
func renderWordleStartAnnouncement(puzzleNumber, wordLength int, hint string) string { func renderWordleStartAnnouncement(puzzleNumber, wordLength int, hint string) string {
base := fmt.Sprintf( base := fmt.Sprintf(
"🟩 **Daily Wordle #%d**\nA new %d-letter puzzle is ready! Work together — 6 guesses shared.", "🟩 **Daily Wordle #%d**\nA new %d-letter puzzle is ready! Work together — %d guesses shared.",
puzzleNumber, wordLength, puzzleNumber, wordLength, wordleMaxGuesses(wordLength),
) )
if hint != "" { if hint != "" {
base += fmt.Sprintf("\n💡 **Hint:** %s", hint) base += fmt.Sprintf("\n💡 **Hint:** %s", hint)
@@ -91,7 +91,7 @@ func renderSolvedAnnouncement(puzzle *WordlePuzzle, definition string, payouts [
// Find the solver. // Find the solver.
lastGuess := puzzle.Guesses[len(puzzle.Guesses)-1] lastGuess := puzzle.Guesses[len(puzzle.Guesses)-1]
sb.WriteString(fmt.Sprintf("🎉 **Solved in %d/6!**\n", len(puzzle.Guesses))) sb.WriteString(fmt.Sprintf("🎉 **Solved in %d/%d!**\n", len(puzzle.Guesses), puzzle.MaxGuesses))
sb.WriteString(fmt.Sprintf("The word was **%s** — solved by %s on guess %d.\n", sb.WriteString(fmt.Sprintf("The word was **%s** — solved by %s on guess %d.\n",
puzzle.Answer, lastGuess.PlayerName, len(puzzle.Guesses))) puzzle.Answer, lastGuess.PlayerName, len(puzzle.Guesses)))
@@ -144,7 +144,7 @@ func renderSolvedAnnouncement(puzzle *WordlePuzzle, definition string, payouts [
func renderFailedAnnouncement(puzzle *WordlePuzzle, definition string) string { func renderFailedAnnouncement(puzzle *WordlePuzzle, definition string) string {
var sb strings.Builder var sb strings.Builder
sb.WriteString(fmt.Sprintf("💀 **Puzzle failed — 6/6**\nThe word was **%s**.\n", puzzle.Answer)) sb.WriteString(fmt.Sprintf("💀 **Puzzle failed — %d/%d**\nThe word was **%s**.\n", puzzle.MaxGuesses, puzzle.MaxGuesses, puzzle.Answer))
if definition != "" { if definition != "" {
sb.WriteString(fmt.Sprintf("\n📖 *%s*\n", definition)) sb.WriteString(fmt.Sprintf("\n📖 *%s*\n", definition))