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

@@ -180,6 +180,13 @@ func (p *WordlePlugin) handleGuess(ctx MessageContext, guess string) error {
}
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.
if guess == puzzle.Answer {
puzzle.Solved = true
@@ -235,7 +242,7 @@ func (p *WordlePlugin) handleNew(ctx MessageContext, args string) error {
category := WordleCategoryEN
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:] {
lower := strings.ToLower(part)
switch lower {
@@ -353,6 +360,11 @@ func (p *WordlePlugin) createAndPostPuzzle(roomID id.RoomID, wordLength int, cat
today, string(roomID), 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.puzzles[roomID] = puzzle
@@ -432,6 +444,29 @@ func wordleCategoryHint(category WordleCategory) string {
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.
func (p *WordlePlugin) PostDailyPuzzle(roomID id.RoomID) error {
today := time.Now().UTC().Format("2006-01-02")
@@ -445,6 +480,9 @@ func (p *WordlePlugin) PostDailyPuzzle(roomID id.RoomID) error {
}
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)
}
@@ -739,12 +777,48 @@ func (p *WordlePlugin) rehydratePuzzles() {
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.puzzles[roomID] = puzzle
p.validCache[pr.pid] = make(map[string]bool)
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
}
// Announce expiry of yesterday's unsolved puzzle before creating the new one.
p.expireUnsolved(gr)
slog.Info("wordle: posting daily puzzle", "room", gr)
if err := p.createAndPostPuzzle(gr, p.defaultLength, WordleCategoryEN); err != nil {
slog.Error("wordle: daily puzzle failed", "err", err)